yam-flow 2.3.0 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +18 -0
- package/CHANGELOG.md +22 -0
- package/README.md +6 -12
- package/ROADMAP.md +2 -0
- package/dist/bin/yam.js +56 -49
- package/dist/lib/skill-installation.d.ts +76 -0
- package/dist/lib/skill-installation.js +554 -0
- package/package.json +3 -2
- package/src/bin/yam.ts +55 -50
- package/src/lib/skill-installation.ts +638 -0
- package/yam.manifest.json +2 -1
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export const INSTALL_RECEIPT_NAME = '.yam-flow-install-receipt.json';
|
|
5
|
+
export const INSTALL_LOCK_NAME = '.yam-flow-install.lock';
|
|
6
|
+
export const INSTALL_RECEIPT_SCHEMA = 'yam.install-receipt.v1';
|
|
7
|
+
export const INSTALL_TRANSACTION_PREFIX = '.yam-flow-install-';
|
|
8
|
+
function message(error) {
|
|
9
|
+
return error instanceof Error ? error.message : String(error);
|
|
10
|
+
}
|
|
11
|
+
function unique(values) {
|
|
12
|
+
return [...new Set(values)];
|
|
13
|
+
}
|
|
14
|
+
function validateManagedNames(names) {
|
|
15
|
+
for (const name of names) {
|
|
16
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
|
|
17
|
+
throw new Error(`unsafe managed skill name: ${name}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function safeRoot(value, label) {
|
|
22
|
+
const resolved = path.resolve(value);
|
|
23
|
+
if (resolved === path.parse(resolved).root) {
|
|
24
|
+
throw new Error(`${label} cannot be a filesystem root: ${resolved}`);
|
|
25
|
+
}
|
|
26
|
+
return resolved;
|
|
27
|
+
}
|
|
28
|
+
async function exists(target) {
|
|
29
|
+
try {
|
|
30
|
+
await fsp.access(target);
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function findInstallRecoveryArtifacts(root) {
|
|
38
|
+
try {
|
|
39
|
+
const entries = await fsp.readdir(root, { withFileTypes: true });
|
|
40
|
+
return entries
|
|
41
|
+
.filter((entry) => entry.name.startsWith(INSTALL_TRANSACTION_PREFIX) && entry.name !== INSTALL_RECEIPT_NAME)
|
|
42
|
+
.map((entry) => path.join(root, entry.name))
|
|
43
|
+
.sort();
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code === 'ENOENT')
|
|
47
|
+
return [];
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function digestFile(file, relativePath) {
|
|
52
|
+
const stat = await fsp.lstat(file);
|
|
53
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
54
|
+
throw new Error(`install source must be a regular file: ${file}`);
|
|
55
|
+
}
|
|
56
|
+
const contents = await fsp.readFile(file);
|
|
57
|
+
return {
|
|
58
|
+
path: relativePath.split(path.sep).join('/'),
|
|
59
|
+
bytes: contents.byteLength,
|
|
60
|
+
sha256: createHash('sha256').update(contents).digest('hex')
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async function collectTreeManifest(root, prefix = '') {
|
|
64
|
+
const stat = await fsp.lstat(root);
|
|
65
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
66
|
+
throw new Error(`install tree must be a regular directory: ${root}`);
|
|
67
|
+
}
|
|
68
|
+
const files = [];
|
|
69
|
+
async function walk(current, relative) {
|
|
70
|
+
const entries = await fsp.readdir(current, { withFileTypes: true });
|
|
71
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
const absolute = path.join(current, entry.name);
|
|
74
|
+
const nextRelative = relative ? path.join(relative, entry.name) : entry.name;
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
await walk(absolute, nextRelative);
|
|
77
|
+
}
|
|
78
|
+
else if (entry.isFile()) {
|
|
79
|
+
files.push(await digestFile(absolute, path.join(prefix, nextRelative)));
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
throw new Error(`unsupported install tree entry: ${absolute}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
await walk(root, '');
|
|
87
|
+
return files.sort((left, right) => left.path.localeCompare(right.path));
|
|
88
|
+
}
|
|
89
|
+
async function expectedInstallManifest(sourceRoot, skills) {
|
|
90
|
+
const referencesRoot = path.join(sourceRoot, 'references');
|
|
91
|
+
const referenceFiles = await collectTreeManifest(referencesRoot);
|
|
92
|
+
if (referenceFiles.length === 0)
|
|
93
|
+
throw new Error(`no reference files found: ${referencesRoot}`);
|
|
94
|
+
const files = [];
|
|
95
|
+
for (const skill of skills) {
|
|
96
|
+
const skillFile = path.join(sourceRoot, 'skills', skill, 'SKILL.md');
|
|
97
|
+
const skillText = await fsp.readFile(skillFile, 'utf8');
|
|
98
|
+
const frontmatter = skillText.match(/^---\n([\s\S]*?)\n---/);
|
|
99
|
+
const name = frontmatter?.[1].match(/^name:\s*([^\n]+)\s*$/m)?.[1]
|
|
100
|
+
?.trim()
|
|
101
|
+
.replace(/^["']|["']$/g, '');
|
|
102
|
+
if (name !== skill) {
|
|
103
|
+
throw new Error(`skill source frontmatter mismatch for ${skill}: ${name || 'missing'}`);
|
|
104
|
+
}
|
|
105
|
+
files.push(await digestFile(skillFile, path.join(skill, 'SKILL.md')));
|
|
106
|
+
for (const reference of referenceFiles) {
|
|
107
|
+
files.push({
|
|
108
|
+
...reference,
|
|
109
|
+
path: path.posix.join(skill, 'references', reference.path)
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return files.sort((left, right) => left.path.localeCompare(right.path));
|
|
114
|
+
}
|
|
115
|
+
async function copyTree(source, target) {
|
|
116
|
+
const stat = await fsp.lstat(source);
|
|
117
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
118
|
+
throw new Error(`install source must be a regular directory: ${source}`);
|
|
119
|
+
}
|
|
120
|
+
await fsp.mkdir(target, { recursive: true });
|
|
121
|
+
const entries = await fsp.readdir(source, { withFileTypes: true });
|
|
122
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
const from = path.join(source, entry.name);
|
|
125
|
+
const to = path.join(target, entry.name);
|
|
126
|
+
if (entry.isDirectory()) {
|
|
127
|
+
await copyTree(from, to);
|
|
128
|
+
}
|
|
129
|
+
else if (entry.isFile()) {
|
|
130
|
+
await fsp.copyFile(from, to);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
throw new Error(`unsupported install source entry: ${from}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function manifestDigest(files) {
|
|
138
|
+
const digest = createHash('sha256');
|
|
139
|
+
for (const file of [...files].sort((left, right) => left.path.localeCompare(right.path))) {
|
|
140
|
+
digest.update(file.path);
|
|
141
|
+
digest.update('\0');
|
|
142
|
+
digest.update(String(file.bytes));
|
|
143
|
+
digest.update('\0');
|
|
144
|
+
digest.update(file.sha256);
|
|
145
|
+
digest.update('\n');
|
|
146
|
+
}
|
|
147
|
+
return digest.digest('hex');
|
|
148
|
+
}
|
|
149
|
+
function manifestIssues(expected, actual, label) {
|
|
150
|
+
const issues = [];
|
|
151
|
+
const expectedByPath = new Map(expected.map((file) => [file.path, file]));
|
|
152
|
+
const actualByPath = new Map(actual.map((file) => [file.path, file]));
|
|
153
|
+
if (actualByPath.size !== actual.length)
|
|
154
|
+
issues.push(`${label} contains duplicate file paths`);
|
|
155
|
+
for (const expectedFile of expected) {
|
|
156
|
+
const actualFile = actualByPath.get(expectedFile.path);
|
|
157
|
+
if (!actualFile) {
|
|
158
|
+
issues.push(`${label} missing file: ${expectedFile.path}`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (actualFile.bytes !== expectedFile.bytes || actualFile.sha256 !== expectedFile.sha256) {
|
|
162
|
+
issues.push(`${label} hash drift: ${expectedFile.path}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const actualFile of actual) {
|
|
166
|
+
if (!expectedByPath.has(actualFile.path))
|
|
167
|
+
issues.push(`${label} unexpected file: ${actualFile.path}`);
|
|
168
|
+
}
|
|
169
|
+
return issues;
|
|
170
|
+
}
|
|
171
|
+
function isFileDigest(value) {
|
|
172
|
+
if (!value || typeof value !== 'object')
|
|
173
|
+
return false;
|
|
174
|
+
const file = value;
|
|
175
|
+
return typeof file.path === 'string'
|
|
176
|
+
&& typeof file.bytes === 'number'
|
|
177
|
+
&& Number.isSafeInteger(file.bytes)
|
|
178
|
+
&& file.bytes >= 0
|
|
179
|
+
&& typeof file.sha256 === 'string'
|
|
180
|
+
&& /^[a-f0-9]{64}$/.test(file.sha256);
|
|
181
|
+
}
|
|
182
|
+
function receiptIssues(receipt, options, destination, expected) {
|
|
183
|
+
const issues = [];
|
|
184
|
+
if (!receipt || typeof receipt !== 'object')
|
|
185
|
+
return ['install receipt must be a JSON object'];
|
|
186
|
+
const value = receipt;
|
|
187
|
+
if (value.schema !== INSTALL_RECEIPT_SCHEMA)
|
|
188
|
+
issues.push(`install receipt schema mismatch: ${value.schema || 'missing'}`);
|
|
189
|
+
if (value.package?.name !== options.packageName)
|
|
190
|
+
issues.push(`install receipt package mismatch: ${value.package?.name || 'missing'}`);
|
|
191
|
+
if (value.package?.version !== options.version)
|
|
192
|
+
issues.push(`install receipt version drift: ${value.package?.version || 'missing'} != ${options.version}`);
|
|
193
|
+
if (value.source?.kind !== 'package-bundled-skills')
|
|
194
|
+
issues.push('install receipt source kind mismatch');
|
|
195
|
+
if (value.source?.identity !== `${options.packageName}@${options.version}`)
|
|
196
|
+
issues.push('install receipt source identity drift');
|
|
197
|
+
if (typeof value.destination !== 'string' || path.resolve(value.destination) !== destination)
|
|
198
|
+
issues.push('install receipt destination drift');
|
|
199
|
+
if (typeof value.installed_at !== 'string' || Number.isNaN(Date.parse(value.installed_at)))
|
|
200
|
+
issues.push('install receipt timestamp missing or invalid');
|
|
201
|
+
if (typeof value.transaction_id !== 'string' || !value.transaction_id)
|
|
202
|
+
issues.push('install receipt transaction id missing');
|
|
203
|
+
if (JSON.stringify(value.skills) !== JSON.stringify(options.skills))
|
|
204
|
+
issues.push('install receipt skill inventory drift');
|
|
205
|
+
if (value.integrity?.algorithm !== 'sha256')
|
|
206
|
+
issues.push('install receipt integrity algorithm mismatch');
|
|
207
|
+
if (value.integrity?.file_count !== expected.length)
|
|
208
|
+
issues.push('install receipt file count drift');
|
|
209
|
+
const expectedDigest = manifestDigest(expected);
|
|
210
|
+
if (value.integrity?.source_digest !== expectedDigest)
|
|
211
|
+
issues.push('install receipt source digest drift');
|
|
212
|
+
if (!Array.isArray(value.integrity?.files) || !value.integrity.files.every(isFileDigest)) {
|
|
213
|
+
issues.push('install receipt file manifest is malformed');
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
issues.push(...manifestIssues(expected, value.integrity.files, 'install receipt'));
|
|
217
|
+
}
|
|
218
|
+
return issues;
|
|
219
|
+
}
|
|
220
|
+
export async function inspectSkillInstallation(options) {
|
|
221
|
+
const destination = safeRoot(options.destination, 'skill destination');
|
|
222
|
+
validateManagedNames(options.skills);
|
|
223
|
+
const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
|
|
224
|
+
const issues = [];
|
|
225
|
+
const recoveryArtifacts = options.ignoreRecoveryArtifacts ? [] : await findInstallRecoveryArtifacts(destination);
|
|
226
|
+
for (const artifact of recoveryArtifacts)
|
|
227
|
+
issues.push(`unfinished install transaction requires inspection: ${artifact}`);
|
|
228
|
+
let expected;
|
|
229
|
+
try {
|
|
230
|
+
expected = await expectedInstallManifest(path.resolve(options.sourceRoot), options.skills);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
const sourceIssue = `install source verification failed: ${message(error)}`;
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
destination,
|
|
237
|
+
receiptPath,
|
|
238
|
+
receiptStatus: 'drift',
|
|
239
|
+
sourceDigest: undefined,
|
|
240
|
+
recoveryArtifacts,
|
|
241
|
+
skills: options.skills.map((skill) => ({ skill, status: 'drift', issues: [sourceIssue] })),
|
|
242
|
+
issues: [...issues, sourceIssue]
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const skillStatuses = [];
|
|
246
|
+
for (const skill of options.skills) {
|
|
247
|
+
const target = path.join(destination, skill);
|
|
248
|
+
const expectedForSkill = expected.filter((file) => file.path.startsWith(`${skill}/`));
|
|
249
|
+
if (!await exists(target)) {
|
|
250
|
+
const issue = `${skill} is not installed`;
|
|
251
|
+
skillStatuses.push({ skill, status: 'missing', issues: [issue] });
|
|
252
|
+
issues.push(issue);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
const installed = (await collectTreeManifest(target, skill));
|
|
257
|
+
const skillIssues = manifestIssues(expectedForSkill, installed, `installed ${skill}`);
|
|
258
|
+
skillStatuses.push({ skill, status: skillIssues.length ? 'drift' : 'ok', issues: skillIssues });
|
|
259
|
+
issues.push(...skillIssues);
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
const issue = `installed ${skill} cannot be verified: ${message(error)}`;
|
|
263
|
+
skillStatuses.push({ skill, status: 'drift', issues: [issue] });
|
|
264
|
+
issues.push(issue);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
let receiptStatus = 'missing';
|
|
268
|
+
let receipt;
|
|
269
|
+
if (!await exists(receiptPath)) {
|
|
270
|
+
issues.push(`install receipt missing: ${receiptPath}`);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
try {
|
|
274
|
+
const parsed = JSON.parse(await fsp.readFile(receiptPath, 'utf8'));
|
|
275
|
+
const problems = receiptIssues(parsed, options, destination, expected);
|
|
276
|
+
if (problems.length) {
|
|
277
|
+
receiptStatus = 'drift';
|
|
278
|
+
issues.push(...problems);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
receiptStatus = 'ok';
|
|
282
|
+
receipt = parsed;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
receiptStatus = 'drift';
|
|
287
|
+
issues.push(`install receipt unreadable: ${message(error)}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
ok: skillStatuses.every((skill) => skill.status === 'ok') && receiptStatus === 'ok' && recoveryArtifacts.length === 0,
|
|
292
|
+
destination,
|
|
293
|
+
receiptPath,
|
|
294
|
+
receiptStatus,
|
|
295
|
+
receipt,
|
|
296
|
+
sourceDigest: manifestDigest(expected),
|
|
297
|
+
recoveryArtifacts,
|
|
298
|
+
skills: skillStatuses,
|
|
299
|
+
issues
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
async function acquireInstallLock(destination) {
|
|
303
|
+
const lockPath = path.join(destination, INSTALL_LOCK_NAME);
|
|
304
|
+
let handle;
|
|
305
|
+
try {
|
|
306
|
+
handle = await fsp.open(lockPath, 'wx', 0o600);
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
if (error.code === 'EEXIST') {
|
|
310
|
+
throw new Error(`another yam install may be active; lock exists at ${lockPath}. Remove it only after confirming no install is running.`);
|
|
311
|
+
}
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }, null, 2));
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
await handle.close().catch(() => undefined);
|
|
319
|
+
await fsp.rm(lockPath, { force: true }).catch(() => undefined);
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
return { handle, lockPath };
|
|
323
|
+
}
|
|
324
|
+
async function moveExistingEntries(root, names, backupRoot, moved) {
|
|
325
|
+
await fsp.mkdir(backupRoot, { recursive: true });
|
|
326
|
+
for (const name of names) {
|
|
327
|
+
const original = path.join(root, name);
|
|
328
|
+
if (!await exists(original))
|
|
329
|
+
continue;
|
|
330
|
+
const backup = path.join(backupRoot, name);
|
|
331
|
+
await fsp.rename(original, backup);
|
|
332
|
+
moved.push({ original, backup });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async function restoreMovedEntries(entries) {
|
|
336
|
+
const errors = [];
|
|
337
|
+
for (const entry of [...entries].reverse()) {
|
|
338
|
+
try {
|
|
339
|
+
if (await exists(entry.original)) {
|
|
340
|
+
throw new Error(`rollback target unexpectedly exists: ${entry.original}`);
|
|
341
|
+
}
|
|
342
|
+
await fsp.mkdir(path.dirname(entry.original), { recursive: true });
|
|
343
|
+
await fsp.rename(entry.backup, entry.original);
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
errors.push(message(error));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return errors;
|
|
350
|
+
}
|
|
351
|
+
async function removeInstalledEntries(entries) {
|
|
352
|
+
const errors = [];
|
|
353
|
+
for (const entry of [...entries].reverse()) {
|
|
354
|
+
try {
|
|
355
|
+
await fsp.rm(entry, { recursive: true, force: true });
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
errors.push(`could not remove new install ${entry}: ${message(error)}`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return errors;
|
|
362
|
+
}
|
|
363
|
+
export async function installSkillSetTransactional(options) {
|
|
364
|
+
const sourceRoot = path.resolve(options.sourceRoot);
|
|
365
|
+
const destination = safeRoot(options.destination, 'skill destination');
|
|
366
|
+
const mirror = options.codexMirror ? safeRoot(options.codexMirror, 'Codex mirror') : undefined;
|
|
367
|
+
const skills = unique(options.skills);
|
|
368
|
+
const managedNames = unique([...skills, ...(options.legacySkills || []), ...(options.retiredSkills || [])]);
|
|
369
|
+
validateManagedNames(managedNames);
|
|
370
|
+
await fsp.mkdir(destination, { recursive: true });
|
|
371
|
+
const lock = await acquireInstallLock(destination);
|
|
372
|
+
const cleanupWarnings = [];
|
|
373
|
+
let result;
|
|
374
|
+
let primaryError;
|
|
375
|
+
try {
|
|
376
|
+
const recoveryArtifacts = [
|
|
377
|
+
...await findInstallRecoveryArtifacts(destination),
|
|
378
|
+
...(mirror && mirror !== destination ? await findInstallRecoveryArtifacts(mirror) : [])
|
|
379
|
+
];
|
|
380
|
+
if (recoveryArtifacts.length) {
|
|
381
|
+
throw new Error(`unfinished yam install transaction found: ${recoveryArtifacts.join(', ')}. `
|
|
382
|
+
+ 'Inspect and preserve any backup state before removing the transaction artifact and retrying.');
|
|
383
|
+
}
|
|
384
|
+
const expected = await expectedInstallManifest(sourceRoot, skills);
|
|
385
|
+
const transactionId = options.transactionId || randomUUID();
|
|
386
|
+
const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-'));
|
|
387
|
+
const stagedRoot = path.join(transactionRoot, 'staged');
|
|
388
|
+
const backupRoot = path.join(transactionRoot, 'backup');
|
|
389
|
+
const stagedReceiptPath = path.join(transactionRoot, 'receipt.json');
|
|
390
|
+
const destinationReceiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
|
|
391
|
+
const receiptBackupPath = path.join(transactionRoot, 'receipt.backup');
|
|
392
|
+
let mirrorTransactionRoot;
|
|
393
|
+
let mirrorBackupRoot;
|
|
394
|
+
const installedEntries = [];
|
|
395
|
+
let destinationBackups = [];
|
|
396
|
+
let mirrorBackups = [];
|
|
397
|
+
let receiptBackedUp = false;
|
|
398
|
+
let receiptInstalled = false;
|
|
399
|
+
let mutationStarted = false;
|
|
400
|
+
try {
|
|
401
|
+
await fsp.mkdir(stagedRoot, { recursive: true });
|
|
402
|
+
for (const skill of skills) {
|
|
403
|
+
const stagedSkill = path.join(stagedRoot, skill);
|
|
404
|
+
await fsp.mkdir(stagedSkill, { recursive: true });
|
|
405
|
+
await fsp.copyFile(path.join(sourceRoot, 'skills', skill, 'SKILL.md'), path.join(stagedSkill, 'SKILL.md'));
|
|
406
|
+
await copyTree(path.join(sourceRoot, 'references'), path.join(stagedSkill, 'references'));
|
|
407
|
+
}
|
|
408
|
+
const stagedManifest = await collectTreeManifest(stagedRoot);
|
|
409
|
+
const stagedIssues = manifestIssues(expected, stagedManifest, 'staged install');
|
|
410
|
+
if (stagedIssues.length)
|
|
411
|
+
throw new Error(stagedIssues.join('; '));
|
|
412
|
+
const receipt = {
|
|
413
|
+
schema: INSTALL_RECEIPT_SCHEMA,
|
|
414
|
+
package: { name: options.packageName, version: options.version },
|
|
415
|
+
source: { kind: 'package-bundled-skills', identity: `${options.packageName}@${options.version}` },
|
|
416
|
+
destination,
|
|
417
|
+
installed_at: (options.now || (() => new Date()))().toISOString(),
|
|
418
|
+
transaction_id: transactionId,
|
|
419
|
+
skills,
|
|
420
|
+
integrity: {
|
|
421
|
+
algorithm: 'sha256',
|
|
422
|
+
file_count: expected.length,
|
|
423
|
+
source_digest: manifestDigest(expected),
|
|
424
|
+
files: expected
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
await fsp.writeFile(stagedReceiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
|
|
428
|
+
await options.failpoint?.({ phase: 'after-stage' });
|
|
429
|
+
if (mirror && mirror !== destination && await exists(mirror)) {
|
|
430
|
+
mirrorTransactionRoot = await fsp.mkdtemp(path.join(mirror, '.yam-flow-install-'));
|
|
431
|
+
await fsp.chmod(mirrorTransactionRoot, 0o700);
|
|
432
|
+
mirrorBackupRoot = path.join(mirrorTransactionRoot, 'backup');
|
|
433
|
+
}
|
|
434
|
+
mutationStarted = true;
|
|
435
|
+
await moveExistingEntries(destination, managedNames, backupRoot, destinationBackups);
|
|
436
|
+
if (await exists(destinationReceiptPath)) {
|
|
437
|
+
await fsp.rename(destinationReceiptPath, receiptBackupPath);
|
|
438
|
+
receiptBackedUp = true;
|
|
439
|
+
}
|
|
440
|
+
await options.failpoint?.({ phase: 'after-backup' });
|
|
441
|
+
for (const skill of skills) {
|
|
442
|
+
const target = path.join(destination, skill);
|
|
443
|
+
await fsp.rename(path.join(stagedRoot, skill), target);
|
|
444
|
+
installedEntries.push(target);
|
|
445
|
+
await options.failpoint?.({ phase: 'skill-installed', skill });
|
|
446
|
+
}
|
|
447
|
+
await fsp.rename(stagedReceiptPath, destinationReceiptPath);
|
|
448
|
+
receiptInstalled = true;
|
|
449
|
+
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
|
+
await options.failpoint?.({ phase: 'before-verify' });
|
|
455
|
+
const inspection = await inspectSkillInstallation({
|
|
456
|
+
sourceRoot,
|
|
457
|
+
destination,
|
|
458
|
+
packageName: options.packageName,
|
|
459
|
+
version: options.version,
|
|
460
|
+
skills,
|
|
461
|
+
ignoreRecoveryArtifacts: true
|
|
462
|
+
});
|
|
463
|
+
if (!inspection.ok)
|
|
464
|
+
throw new Error(`post-install verification failed: ${inspection.issues.join('; ')}`);
|
|
465
|
+
for (const cleanup of [transactionRoot, mirrorTransactionRoot]) {
|
|
466
|
+
if (!cleanup)
|
|
467
|
+
continue;
|
|
468
|
+
try {
|
|
469
|
+
await fsp.rm(cleanup, { recursive: true, force: true });
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
cleanupWarnings.push(`verified install retained cleanup artifact ${cleanup}: ${message(error)}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
result = {
|
|
476
|
+
receipt,
|
|
477
|
+
receiptPath: destinationReceiptPath,
|
|
478
|
+
installedFiles: expected.length,
|
|
479
|
+
sourceDigest: receipt.integrity.source_digest,
|
|
480
|
+
cleanupWarnings
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
const rollbackErrors = [];
|
|
485
|
+
if (mutationStarted) {
|
|
486
|
+
if (receiptInstalled)
|
|
487
|
+
rollbackErrors.push(...await removeInstalledEntries([destinationReceiptPath]));
|
|
488
|
+
rollbackErrors.push(...await removeInstalledEntries(installedEntries));
|
|
489
|
+
rollbackErrors.push(...await restoreMovedEntries(destinationBackups));
|
|
490
|
+
if (receiptBackedUp) {
|
|
491
|
+
try {
|
|
492
|
+
if (await exists(destinationReceiptPath))
|
|
493
|
+
throw new Error(`rollback target unexpectedly exists: ${destinationReceiptPath}`);
|
|
494
|
+
await fsp.rename(receiptBackupPath, destinationReceiptPath);
|
|
495
|
+
}
|
|
496
|
+
catch (receiptError) {
|
|
497
|
+
rollbackErrors.push(message(receiptError));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
rollbackErrors.push(...await restoreMovedEntries(mirrorBackups));
|
|
501
|
+
}
|
|
502
|
+
if (rollbackErrors.length === 0) {
|
|
503
|
+
const cleanupErrors = [];
|
|
504
|
+
for (const cleanup of [transactionRoot, mirrorTransactionRoot]) {
|
|
505
|
+
if (!cleanup)
|
|
506
|
+
continue;
|
|
507
|
+
try {
|
|
508
|
+
await fsp.rm(cleanup, { recursive: true, force: true });
|
|
509
|
+
}
|
|
510
|
+
catch (cleanupError) {
|
|
511
|
+
cleanupErrors.push(`${cleanup}: ${message(cleanupError)}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (cleanupErrors.length) {
|
|
515
|
+
throw new Error(`yam install failed; previous installation state restored, but transaction cleanup is incomplete: ${message(error)}. `
|
|
516
|
+
+ `Cleanup errors: ${cleanupErrors.join('; ')}`);
|
|
517
|
+
}
|
|
518
|
+
throw new Error(`yam install failed; previous installation state restored: ${message(error)}`);
|
|
519
|
+
}
|
|
520
|
+
throw new Error(`yam install failed and rollback is incomplete: ${message(error)}. `
|
|
521
|
+
+ `Recovery artifacts were kept at ${transactionRoot}${mirrorTransactionRoot ? ` and ${mirrorTransactionRoot}` : ''}. `
|
|
522
|
+
+ `Rollback errors: ${rollbackErrors.join('; ')}`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
primaryError = error;
|
|
527
|
+
}
|
|
528
|
+
const lockCleanupErrors = [];
|
|
529
|
+
try {
|
|
530
|
+
await lock.handle.close();
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
lockCleanupErrors.push(message(error));
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
await fsp.rm(lock.lockPath, { force: true });
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
lockCleanupErrors.push(message(error));
|
|
540
|
+
}
|
|
541
|
+
if (lockCleanupErrors.length) {
|
|
542
|
+
const warning = `install lock cleanup failed at ${lock.lockPath}: ${lockCleanupErrors.join('; ')}`;
|
|
543
|
+
if (primaryError)
|
|
544
|
+
primaryError = new Error(`${message(primaryError)}. ${warning}`);
|
|
545
|
+
else
|
|
546
|
+
cleanupWarnings.push(warning);
|
|
547
|
+
}
|
|
548
|
+
if (primaryError)
|
|
549
|
+
throw primaryError;
|
|
550
|
+
if (!result)
|
|
551
|
+
throw new Error('yam install ended without a result');
|
|
552
|
+
result.cleanupWarnings = cleanupWarnings;
|
|
553
|
+
return result;
|
|
554
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yam-flow",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.1",
|
|
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 ./dist/bin/yam.js verify && node ./dist/bin/yam.js doctor",
|
|
55
|
+
"verify:self": "npm run build && node --check ./dist/bin/yam.js && node --check ./dist/lib/trust-kernel.js && node --check ./dist/lib/skill-installation.js && node ./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
|
+
"install-transaction-smoke": "npm run build && node ./scripts/install-transaction-smoke.mjs",
|
|
67
68
|
"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",
|
|
68
69
|
"budget": "node ./dist/bin/yam.js budget",
|
|
69
70
|
"measure": "node ./dist/bin/yam.js measure",
|