rcf-lite 0.15.0 → 0.16.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 +13 -0
- package/fixtures/canary-manifest.json +101 -1
- package/guidance/harness-template.md +9 -0
- package/guidance/managed/agent-instructions-block.hash +1 -1
- package/guidance/managed/agent-instructions-block.md +9 -0
- package/package.json +1 -1
- package/releases/releases.yaml +11 -1
- package/src/blueprint/index.js +22 -0
- package/src/blueprint/library-cache.js +143 -0
- package/src/blueprint/library-fetcher-git.js +347 -0
- package/src/blueprint/library-fetcher-tarball.js +379 -0
- package/src/blueprint/shelf-resolver.js +100 -7
- package/src/cli/blueprint-library.js +397 -80
- package/src/cli/blueprint.js +1 -0
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// `rcf define blueprint library <verb>` CLI.
|
|
2
2
|
//
|
|
3
|
-
// Verbs (Phase 2b):
|
|
4
|
-
// add register an external library on this project
|
|
5
|
-
//
|
|
3
|
+
// Verbs (Phase 2b + 2c):
|
|
4
|
+
// add register an external library on this project. Accepts a
|
|
5
|
+
// local path, a `git+<url>#<tag-or-sha>` ref, or a tarball
|
|
6
|
+
// URL with `--sha256 <hex>`.
|
|
6
7
|
// list list registered libraries
|
|
7
8
|
// remove unregister a library (refuses when any applied blueprint on
|
|
8
|
-
// the project came through the library)
|
|
9
|
-
// refresh re-
|
|
9
|
+
// the project came through the library) and removes the cache
|
|
10
|
+
// refresh re-fetch the pinned ref, verify against the registry's
|
|
11
|
+
// resolved sha or tarball digest, refuse on drift
|
|
10
12
|
//
|
|
11
|
-
// Spec: external-blueprint-libraries-spec-2026-08-31.md sections 4, 8.
|
|
13
|
+
// Spec: external-blueprint-libraries-spec-2026-08-31.md sections 4, 6, 8.
|
|
12
14
|
|
|
13
15
|
import { isAbsolute, resolve } from 'node:path';
|
|
14
16
|
import { stat } from 'node:fs/promises';
|
|
@@ -27,6 +29,19 @@ import {
|
|
|
27
29
|
} from '../blueprint/library-registry.js';
|
|
28
30
|
import { knownShelfSlugs, packagedShelfPath } from '../blueprint/shelf-resolver.js';
|
|
29
31
|
import { walkTree } from '#core/store';
|
|
32
|
+
import {
|
|
33
|
+
absoluteCachePath,
|
|
34
|
+
ensureEmptyCache,
|
|
35
|
+
relativeCachePath,
|
|
36
|
+
removeCache,
|
|
37
|
+
resolveCachePath,
|
|
38
|
+
} from '../blueprint/library-cache.js';
|
|
39
|
+
import {
|
|
40
|
+
fetchGitLibrary,
|
|
41
|
+
parseGitRef,
|
|
42
|
+
resolveRemoteSha,
|
|
43
|
+
} from '../blueprint/library-fetcher-git.js';
|
|
44
|
+
import { fetchTarballLibrary } from '../blueprint/library-fetcher-tarball.js';
|
|
30
45
|
|
|
31
46
|
/**
|
|
32
47
|
* Union of core-shelf blueprint slugs used for the prefix-collision
|
|
@@ -49,33 +64,46 @@ export const LIBRARY_HELP = `Usage: rcf define blueprint library <verb> [options
|
|
|
49
64
|
|
|
50
65
|
Verbs:
|
|
51
66
|
add <ref> Register an external library on this project.
|
|
52
|
-
<ref> is
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
67
|
+
<ref> is one of:
|
|
68
|
+
- a local absolute or relative path to a library
|
|
69
|
+
root (a directory containing library.json);
|
|
70
|
+
- a git ref pinned to an annotated tag or a
|
|
71
|
+
commit sha, e.g.
|
|
72
|
+
git+https://github.com/wsd-team/wsd-blueprint-library.git#v1.2.0
|
|
73
|
+
or git+ssh://git@github.com/... . Floating
|
|
74
|
+
branches (main, master, HEAD, latest) refuse.
|
|
75
|
+
- a tarball URL (.tar / .tar.gz / .tgz) paired
|
|
76
|
+
with --sha256 <hex>; SHA-256 is verified on
|
|
77
|
+
the download bytes and stored as the pin.
|
|
78
|
+
Fetches metadata, runs review-on-add, writes an
|
|
79
|
+
entry to rcf/blueprint-libraries.json, and lands
|
|
80
|
+
the extracted content under
|
|
81
|
+
rcf/.blueprint-libraries/<prefix>/<ref>/ (checked
|
|
82
|
+
into git as ordinary tree content so a fresh
|
|
83
|
+
clone can 'rcf define blueprint list' without a
|
|
84
|
+
re-fetch).
|
|
61
85
|
|
|
62
86
|
list [--json] List every registered library on this project
|
|
63
87
|
(prefix, source, publisher, blueprint count).
|
|
64
88
|
|
|
65
|
-
remove <prefix> Unregister a library
|
|
66
|
-
blueprint on the project came
|
|
67
|
-
library.
|
|
89
|
+
remove <prefix> Unregister a library and drop its cache. Refuses
|
|
90
|
+
when any applied blueprint on the project came
|
|
91
|
+
through this library.
|
|
68
92
|
|
|
69
|
-
refresh <prefix> Re-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
93
|
+
refresh <prefix> Re-fetch the pinned ref for a network library and
|
|
94
|
+
verify against the registry's resolved sha or
|
|
95
|
+
tarball digest; refuse-and-report on drift (spec
|
|
96
|
+
§6.4 / §9.12: annotated-tag moves are a supply
|
|
97
|
+
chain event, not an auto-update). Local sources
|
|
98
|
+
re-validate the on-disk library.json against the
|
|
99
|
+
registry snapshot.
|
|
74
100
|
|
|
75
101
|
Options:
|
|
76
102
|
--prefix <slug> (add) Override the library's declared prefix.
|
|
77
103
|
Rarely needed; only useful when two libraries
|
|
78
104
|
collide on prefix locally.
|
|
105
|
+
--sha256 <hex> (add) Required when <ref> is a tarball URL. The
|
|
106
|
+
expected SHA-256 (64 hex chars) of the download.
|
|
79
107
|
--i-have-reviewed (add) Skip the interactive review-on-add prompt.
|
|
80
108
|
Required companion to --no-review when scripting;
|
|
81
109
|
the two-flag form keeps a library-add trust
|
|
@@ -90,6 +118,7 @@ Options:
|
|
|
90
118
|
|
|
91
119
|
const LIBRARY_OPTION_SPEC = {
|
|
92
120
|
prefix: { type: 'string' },
|
|
121
|
+
sha256: { type: 'string' },
|
|
93
122
|
'i-have-reviewed': { type: 'boolean' },
|
|
94
123
|
'no-review': { type: 'boolean' },
|
|
95
124
|
json: { type: 'boolean' },
|
|
@@ -143,33 +172,26 @@ async function handleAdd({ args, parsed, projectRoot, now, stdout, stderr, stdin
|
|
|
143
172
|
}
|
|
144
173
|
const ref = args[0];
|
|
145
174
|
|
|
146
|
-
// Phase 2b: local sources only. Fetchers land in 2c.
|
|
147
175
|
const kind = classifySourceRef(ref);
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
176
|
+
// Fetch phase: land the library on disk under a temporary cachePath
|
|
177
|
+
// computed against the library's declared prefix (loader reads it from
|
|
178
|
+
// library.json) once we know it. For local sources the cache path is
|
|
179
|
+
// the operator's own directory - no copy, no cache slot. For git and
|
|
180
|
+
// tarball we fetch into an on-disk cache and load from there.
|
|
181
|
+
const dryRun = parsed.values['dry-run'] === true;
|
|
182
|
+
const fetchResult = await performFetch({ kind, ref, parsed, projectRoot, stderr });
|
|
183
|
+
if (typeof fetchResult === 'number') return fetchResult;
|
|
156
184
|
|
|
157
|
-
const
|
|
158
|
-
let libStat;
|
|
159
|
-
try {
|
|
160
|
-
libStat = await stat(localRoot);
|
|
161
|
-
} catch (err) {
|
|
162
|
-
stderr.write(`[error] blueprint library add: path '${localRoot}' cannot be read: ${err.message}\n`);
|
|
163
|
-
return 2;
|
|
164
|
-
}
|
|
165
|
-
if (!libStat.isDirectory()) {
|
|
166
|
-
stderr.write(`[error] blueprint library add: path '${localRoot}' is not a directory.\n`);
|
|
167
|
-
return 2;
|
|
168
|
-
}
|
|
185
|
+
const { libraryRoot, sourceKind, sourceRef, resolvedSha, tarballSha256, cachePathAbs, cachePathRel } = fetchResult;
|
|
169
186
|
|
|
170
|
-
const library = await loadLibrary(
|
|
187
|
+
const library = await loadLibrary(libraryRoot, { validateBlueprints: true });
|
|
171
188
|
if (isRcfError(library)) {
|
|
172
189
|
stderr.write(`[error] blueprint library add: ${library.message}\n`);
|
|
190
|
+
if (sourceKind !== 'local' && cachePathAbs) {
|
|
191
|
+
// Roll back a fetched-but-invalid library so the cache does not
|
|
192
|
+
// linger as ghost state.
|
|
193
|
+
await removeCache(cachePathAbs);
|
|
194
|
+
}
|
|
173
195
|
return 2;
|
|
174
196
|
}
|
|
175
197
|
|
|
@@ -210,20 +232,31 @@ async function handleAdd({ args, parsed, projectRoot, now, stdout, stderr, stdin
|
|
|
210
232
|
return 2;
|
|
211
233
|
}
|
|
212
234
|
if (wantsReview) {
|
|
213
|
-
printReview({ stdout, ref, library, libraryPrefix, coreReservations });
|
|
235
|
+
printReview({ stdout, ref, library, libraryPrefix, coreReservations, sourceKind, resolvedSha, tarballSha256 });
|
|
214
236
|
if (!iHaveReviewed) {
|
|
215
237
|
const proceed = await prompt(stdin, stdout, 'Proceed with add? [y/N] ');
|
|
216
238
|
if (!/^y(es)?$/i.test((proceed ?? '').trim())) {
|
|
217
239
|
stdout.write('[blueprint library] aborted by operator; no registry entry written.\n');
|
|
240
|
+
if (sourceKind !== 'local' && cachePathAbs) {
|
|
241
|
+
await removeCache(cachePathAbs);
|
|
242
|
+
}
|
|
218
243
|
return 0;
|
|
219
244
|
}
|
|
220
245
|
}
|
|
221
246
|
}
|
|
222
247
|
|
|
248
|
+
const provenance = { tier: sourceKind };
|
|
249
|
+
if (sourceKind === 'git') {
|
|
250
|
+
provenance.shaVerifiedAt = now.toISOString();
|
|
251
|
+
} else if (sourceKind === 'tarball') {
|
|
252
|
+
provenance.shaVerifiedAt = now.toISOString();
|
|
253
|
+
provenance.tarballSha256 = tarballSha256;
|
|
254
|
+
}
|
|
223
255
|
const entry = {
|
|
224
256
|
libraryPrefix,
|
|
225
|
-
sourceKind
|
|
226
|
-
sourceRef
|
|
257
|
+
sourceKind,
|
|
258
|
+
sourceRef,
|
|
259
|
+
...(resolvedSha ? { resolvedSha } : {}),
|
|
227
260
|
displayName: library.displayName,
|
|
228
261
|
publisher: { ...library.publisher },
|
|
229
262
|
libraryRef: library.libraryRef,
|
|
@@ -231,39 +264,200 @@ async function handleAdd({ args, parsed, projectRoot, now, stdout, stderr, stdin
|
|
|
231
264
|
blueprints: library.blueprints.map((b) => ({ slug: b.slug, path: b.path })),
|
|
232
265
|
addedAt: now.toISOString(),
|
|
233
266
|
reviewedBy: 'operator',
|
|
234
|
-
provenance
|
|
235
|
-
cachePath:
|
|
267
|
+
provenance,
|
|
268
|
+
cachePath: sourceKind === 'local' ? libraryRoot : cachePathRel,
|
|
236
269
|
};
|
|
237
270
|
|
|
238
271
|
const nextRegistry = {
|
|
239
272
|
registryVersion: registry.registryVersion,
|
|
240
273
|
libraries: [...registry.libraries, entry],
|
|
241
274
|
};
|
|
242
|
-
const write = await writeLibraryRegistry(projectRoot, nextRegistry, { dryRun
|
|
275
|
+
const write = await writeLibraryRegistry(projectRoot, nextRegistry, { dryRun });
|
|
243
276
|
if (isRcfError(write)) {
|
|
244
277
|
stderr.write(`[error] blueprint library add: ${write.message}\n`);
|
|
278
|
+
if (sourceKind !== 'local' && cachePathAbs) await removeCache(cachePathAbs);
|
|
245
279
|
return 2;
|
|
246
280
|
}
|
|
247
281
|
if (parsed.values.json) {
|
|
248
282
|
stdout.write(`${JSON.stringify({
|
|
249
|
-
added: !
|
|
250
|
-
dryRun
|
|
283
|
+
added: !dryRun,
|
|
284
|
+
dryRun,
|
|
251
285
|
libraryPrefix: entry.libraryPrefix,
|
|
286
|
+
sourceKind: entry.sourceKind,
|
|
287
|
+
resolvedSha: entry.resolvedSha ?? null,
|
|
288
|
+
tarballSha256: entry.provenance.tarballSha256 ?? null,
|
|
289
|
+
cachePath: entry.cachePath,
|
|
252
290
|
blueprintCount: entry.blueprints.length,
|
|
253
291
|
registryPath: write.path,
|
|
254
292
|
})}\n`);
|
|
255
293
|
return 0;
|
|
256
294
|
}
|
|
257
295
|
if (!parsed.values.quiet) {
|
|
258
|
-
if (
|
|
259
|
-
stdout.write(`[blueprint library] dry-run: would add '${libraryPrefix}' (${entry.blueprints.length} blueprint(s)) to ${write.path}.\n`);
|
|
296
|
+
if (dryRun) {
|
|
297
|
+
stdout.write(`[blueprint library] dry-run: would add '${libraryPrefix}' (${entry.blueprints.length} blueprint(s), source=${sourceKind}) to ${write.path}.\n`);
|
|
260
298
|
} else {
|
|
261
|
-
|
|
299
|
+
const pinNote = resolvedSha ? ` (sha ${resolvedSha.slice(0, 12)})` : (tarballSha256 ? ` (sha256 ${tarballSha256.slice(0, 12)})` : '');
|
|
300
|
+
stdout.write(`[blueprint library] added '${libraryPrefix}' (${entry.blueprints.length} blueprint(s), ${sourceKind})${pinNote} to ${write.path}.\n`);
|
|
262
301
|
}
|
|
263
302
|
}
|
|
264
303
|
return 0;
|
|
265
304
|
}
|
|
266
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Land the library on disk. Returns either a numeric exit code (error
|
|
308
|
+
* already reported to stderr) or an object describing the fetched
|
|
309
|
+
* library placement.
|
|
310
|
+
*
|
|
311
|
+
* @returns {Promise<number | { libraryRoot: string, sourceKind: 'local' | 'git' | 'tarball', sourceRef: string, resolvedSha?: string, tarballSha256?: string, cachePathAbs?: string, cachePathRel?: string }>}
|
|
312
|
+
*/
|
|
313
|
+
async function performFetch({ kind, ref, parsed, projectRoot, stderr }) {
|
|
314
|
+
const dryRun = parsed.values['dry-run'] === true;
|
|
315
|
+
if (kind === 'local') {
|
|
316
|
+
const localRoot = isAbsolute(ref) ? ref : resolve(projectRoot, ref);
|
|
317
|
+
let libStat;
|
|
318
|
+
try {
|
|
319
|
+
libStat = await stat(localRoot);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
stderr.write(`[error] blueprint library add: path '${localRoot}' cannot be read: ${err.message}\n`);
|
|
322
|
+
return 2;
|
|
323
|
+
}
|
|
324
|
+
if (!libStat.isDirectory()) {
|
|
325
|
+
stderr.write(`[error] blueprint library add: path '${localRoot}' is not a directory.\n`);
|
|
326
|
+
return 2;
|
|
327
|
+
}
|
|
328
|
+
return { libraryRoot: localRoot, sourceKind: 'local', sourceRef: localRoot };
|
|
329
|
+
}
|
|
330
|
+
if (kind === 'git') {
|
|
331
|
+
const parsed_ref = parseGitRef(ref);
|
|
332
|
+
if (isRcfError(parsed_ref)) {
|
|
333
|
+
stderr.write(`[error] blueprint library add: ${parsed_ref.message}\n`);
|
|
334
|
+
return 2;
|
|
335
|
+
}
|
|
336
|
+
// Provisional cache slot keyed by tag or short sha. The final slot
|
|
337
|
+
// moves to the library-declared libraryRef if it differs, but for
|
|
338
|
+
// git the ref-as-typed is what the operator will use to look this
|
|
339
|
+
// library up in the registry.
|
|
340
|
+
const provisionalRef = parsed_ref.ref;
|
|
341
|
+
// We do not yet know libraryPrefix (that comes from library.json)
|
|
342
|
+
// so we fetch into a scratch dir and rename later based on the
|
|
343
|
+
// loaded prefix. Fetch into <projectRoot>/rcf/.blueprint-libraries/.pending-<pid>/
|
|
344
|
+
const scratchPrefix = `.pending-${process.pid}-${Date.now().toString(36)}`;
|
|
345
|
+
const scratchAbs = absoluteCachePath(projectRoot, scratchPrefix, provisionalRef);
|
|
346
|
+
const prepErr = await ensureEmptyCache(scratchAbs, { replace: true });
|
|
347
|
+
if (prepErr) {
|
|
348
|
+
stderr.write(`[error] blueprint library add: ${prepErr.message}\n`);
|
|
349
|
+
return 2;
|
|
350
|
+
}
|
|
351
|
+
if (dryRun) {
|
|
352
|
+
stderr.write(`[error] blueprint library add: --dry-run is not supported for network fetches (would fetch ${parsed_ref.url}#${parsed_ref.ref}).\n`);
|
|
353
|
+
await removeCache(scratchAbs);
|
|
354
|
+
return 2;
|
|
355
|
+
}
|
|
356
|
+
const fetched = await fetchGitLibrary({ url: parsed_ref.url, ref: parsed_ref.ref, refKind: parsed_ref.refKind, targetDir: scratchAbs });
|
|
357
|
+
if (isRcfError(fetched)) {
|
|
358
|
+
stderr.write(`[error] blueprint library add: ${fetched.message}\n`);
|
|
359
|
+
await removeCache(scratchAbs);
|
|
360
|
+
return 2;
|
|
361
|
+
}
|
|
362
|
+
// Peek at library.json before we settle the cache slot so we know
|
|
363
|
+
// the prefix and libraryRef the operator committed to.
|
|
364
|
+
const peek = await loadLibrary(scratchAbs, { validateBlueprints: false });
|
|
365
|
+
if (isRcfError(peek)) {
|
|
366
|
+
stderr.write(`[error] blueprint library add: ${peek.message}\n`);
|
|
367
|
+
await removeCache(scratchAbs);
|
|
368
|
+
return 2;
|
|
369
|
+
}
|
|
370
|
+
const finalRel = relativeCachePath(peek.libraryPrefix, peek.libraryRef);
|
|
371
|
+
const finalAbs = absoluteCachePath(projectRoot, peek.libraryPrefix, peek.libraryRef);
|
|
372
|
+
const settleErr = await settleFinal(scratchAbs, finalAbs);
|
|
373
|
+
if (settleErr) {
|
|
374
|
+
stderr.write(`[error] blueprint library add: ${settleErr.message}\n`);
|
|
375
|
+
return 2;
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
libraryRoot: finalAbs,
|
|
379
|
+
sourceKind: 'git',
|
|
380
|
+
sourceRef: ref,
|
|
381
|
+
resolvedSha: fetched.resolvedSha,
|
|
382
|
+
cachePathAbs: finalAbs,
|
|
383
|
+
cachePathRel: finalRel,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if (kind === 'tarball') {
|
|
387
|
+
const expected = typeof parsed.values.sha256 === 'string' ? parsed.values.sha256 : '';
|
|
388
|
+
if (!expected) {
|
|
389
|
+
stderr.write("[error] blueprint library add: tarball source requires --sha256 <hex> (64 hex chars); the SHA-256 is the pin (spec §6.1).\n");
|
|
390
|
+
return 2;
|
|
391
|
+
}
|
|
392
|
+
if (dryRun) {
|
|
393
|
+
stderr.write(`[error] blueprint library add: --dry-run is not supported for network fetches (would fetch tarball ${ref}).\n`);
|
|
394
|
+
return 2;
|
|
395
|
+
}
|
|
396
|
+
const scratchPrefix = `.pending-${process.pid}-${Date.now().toString(36)}`;
|
|
397
|
+
const provisionalRef = 'downloading';
|
|
398
|
+
const scratchAbs = absoluteCachePath(projectRoot, scratchPrefix, provisionalRef);
|
|
399
|
+
const prepErr = await ensureEmptyCache(scratchAbs, { replace: true });
|
|
400
|
+
if (prepErr) {
|
|
401
|
+
stderr.write(`[error] blueprint library add: ${prepErr.message}\n`);
|
|
402
|
+
return 2;
|
|
403
|
+
}
|
|
404
|
+
const fetched = await fetchTarballLibrary({ url: ref, expectedSha256: expected, targetDir: scratchAbs });
|
|
405
|
+
if (isRcfError(fetched)) {
|
|
406
|
+
stderr.write(`[error] blueprint library add: ${fetched.message}\n`);
|
|
407
|
+
await removeCache(scratchAbs);
|
|
408
|
+
return 2;
|
|
409
|
+
}
|
|
410
|
+
const peek = await loadLibrary(scratchAbs, { validateBlueprints: false });
|
|
411
|
+
if (isRcfError(peek)) {
|
|
412
|
+
stderr.write(`[error] blueprint library add: ${peek.message}\n`);
|
|
413
|
+
await removeCache(scratchAbs);
|
|
414
|
+
return 2;
|
|
415
|
+
}
|
|
416
|
+
const finalRel = relativeCachePath(peek.libraryPrefix, peek.libraryRef);
|
|
417
|
+
const finalAbs = absoluteCachePath(projectRoot, peek.libraryPrefix, peek.libraryRef);
|
|
418
|
+
const settleErr = await settleFinal(scratchAbs, finalAbs);
|
|
419
|
+
if (settleErr) {
|
|
420
|
+
stderr.write(`[error] blueprint library add: ${settleErr.message}\n`);
|
|
421
|
+
return 2;
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
libraryRoot: finalAbs,
|
|
425
|
+
sourceKind: 'tarball',
|
|
426
|
+
sourceRef: ref,
|
|
427
|
+
tarballSha256: fetched.tarballSha256,
|
|
428
|
+
cachePathAbs: finalAbs,
|
|
429
|
+
cachePathRel: finalRel,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
stderr.write(`[error] blueprint library add: unknown source kind '${kind}'.\n`);
|
|
433
|
+
return 2;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function settleFinal(scratchAbs, finalAbs) {
|
|
437
|
+
const { rename, mkdir, rm, readdir } = await import('node:fs/promises');
|
|
438
|
+
const { dirname } = await import('node:path');
|
|
439
|
+
try {
|
|
440
|
+
await rm(finalAbs, { recursive: true, force: true });
|
|
441
|
+
await mkdir(dirname(finalAbs), { recursive: true });
|
|
442
|
+
await rename(scratchAbs, finalAbs);
|
|
443
|
+
// Sweep the empty `.pending-<pid>-<ts>/` parent dir so a chain of
|
|
444
|
+
// adds does not leave scratch skeletons under the cache root. Only
|
|
445
|
+
// remove if the parent is now empty AND itself sits under
|
|
446
|
+
// `.blueprint-libraries/` (safety belt against removing a
|
|
447
|
+
// legitimate prefix directory).
|
|
448
|
+
const scratchParent = dirname(scratchAbs);
|
|
449
|
+
if (scratchParent.includes('.blueprint-libraries')) {
|
|
450
|
+
try {
|
|
451
|
+
const remaining = await readdir(scratchParent);
|
|
452
|
+
if (remaining.length === 0) await rm(scratchParent, { recursive: true, force: true });
|
|
453
|
+
} catch { /* dir already gone */ }
|
|
454
|
+
}
|
|
455
|
+
return null;
|
|
456
|
+
} catch (err) {
|
|
457
|
+
return { kind: 'ioFailure', message: `library cache settle: ${err.message}` };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
267
461
|
async function handleList({ parsed, projectRoot, stdout, stderr }) {
|
|
268
462
|
const registry = await readLibraryRegistry(projectRoot);
|
|
269
463
|
if (isRcfError(registry)) {
|
|
@@ -343,11 +537,22 @@ async function handleRemove({ args, parsed, projectRoot, stdout, stderr }) {
|
|
|
343
537
|
registryVersion: registry.registryVersion,
|
|
344
538
|
libraries: registry.libraries.filter((l) => l.libraryPrefix !== libraryPrefix),
|
|
345
539
|
};
|
|
346
|
-
const
|
|
540
|
+
const dryRun = parsed.values['dry-run'] === true;
|
|
541
|
+
const write = await writeLibraryRegistry(projectRoot, nextRegistry, { dryRun });
|
|
347
542
|
if (isRcfError(write)) {
|
|
348
543
|
stderr.write(`[error] blueprint library remove: ${write.message}\n`);
|
|
349
544
|
return 2;
|
|
350
545
|
}
|
|
546
|
+
// Clean the on-disk cache for network sources so a subsequent `add`
|
|
547
|
+
// starts from a clean slate. Local sources point at the operator's
|
|
548
|
+
// own directory and must never be touched.
|
|
549
|
+
if (!dryRun && entry.sourceKind !== 'local') {
|
|
550
|
+
const cachePathAbs = resolveCachePath(projectRoot, entry.cachePath);
|
|
551
|
+
const cacheErr = await removeCache(cachePathAbs);
|
|
552
|
+
if (cacheErr) {
|
|
553
|
+
stderr.write(`[warn] blueprint library remove: registry entry gone; cache cleanup failed: ${cacheErr.message}\n`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
351
556
|
if (!parsed.values.quiet) {
|
|
352
557
|
stdout.write(`[blueprint library] removed '${libraryPrefix}' from ${write.path}.\n`);
|
|
353
558
|
}
|
|
@@ -370,45 +575,157 @@ async function handleRefresh({ args, parsed, projectRoot, stdout, stderr }) {
|
|
|
370
575
|
stderr.write(`[error] blueprint library refresh: library '${libraryPrefix}' is not registered.\n`);
|
|
371
576
|
return 2;
|
|
372
577
|
}
|
|
373
|
-
if (entry.sourceKind
|
|
374
|
-
|
|
375
|
-
|
|
578
|
+
if (entry.sourceKind === 'local') {
|
|
579
|
+
const library = await loadLibrary(entry.cachePath, { validateBlueprints: true });
|
|
580
|
+
if (isRcfError(library)) {
|
|
581
|
+
stderr.write(`[error] blueprint library refresh: ${library.message}\n`);
|
|
582
|
+
return 2;
|
|
583
|
+
}
|
|
584
|
+
const drifted = compareLibrarySnapshot(entry, library);
|
|
585
|
+
if (drifted.length > 0) {
|
|
586
|
+
stderr.write(`[blueprint library refresh] '${libraryPrefix}' has drifted from the registered snapshot:\n`);
|
|
587
|
+
for (const d of drifted) stderr.write(` ${d}\n`);
|
|
588
|
+
stderr.write("re-run 'rcf define blueprint library add <ref>' to pick up the newer library (spec §10.1: freshness is advisory, adoption is an operator act).\n");
|
|
589
|
+
return 3;
|
|
590
|
+
}
|
|
591
|
+
if (!parsed.values.quiet) {
|
|
592
|
+
stdout.write(`[blueprint library] '${libraryPrefix}' refresh clean: on-disk library matches the registry snapshot.\n`);
|
|
593
|
+
}
|
|
594
|
+
return 0;
|
|
376
595
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
596
|
+
if (entry.sourceKind === 'git') {
|
|
597
|
+
const parsedGit = parseGitRef(entry.sourceRef);
|
|
598
|
+
if (isRcfError(parsedGit)) {
|
|
599
|
+
stderr.write(`[error] blueprint library refresh: registered sourceRef is malformed: ${parsedGit.message}\n`);
|
|
600
|
+
return 2;
|
|
601
|
+
}
|
|
602
|
+
const upstream = await resolveRemoteSha({ url: parsedGit.url, ref: parsedGit.ref, refKind: parsedGit.refKind });
|
|
603
|
+
if (isRcfError(upstream)) {
|
|
604
|
+
stderr.write(`[error] blueprint library refresh: ${upstream.message}\n`);
|
|
605
|
+
return 2;
|
|
606
|
+
}
|
|
607
|
+
if (typeof entry.resolvedSha === 'string' && entry.resolvedSha.toLowerCase() !== upstream.resolvedSha) {
|
|
608
|
+
stderr.write(
|
|
609
|
+
`[blueprint library refresh] '${libraryPrefix}' pin drift: the tag '${parsedGit.ref}' at ${parsedGit.url} has moved from sha `
|
|
610
|
+
+ `${entry.resolvedSha} to ${upstream.resolvedSha}. Publishers should not move annotated tags. `
|
|
611
|
+
+ `Re-add explicitly if intentional (spec §6.4 / §9.12).\n`,
|
|
612
|
+
);
|
|
613
|
+
return 3;
|
|
614
|
+
}
|
|
615
|
+
// Sha matches; re-fetch into a scratch cache and verify the tree
|
|
616
|
+
// still validates. This catches the (rare) case of a cache tree
|
|
617
|
+
// that has been corrupted or hand-edited between refreshes.
|
|
618
|
+
const scratchPrefix = `.pending-${process.pid}-${Date.now().toString(36)}`;
|
|
619
|
+
const scratchAbs = absoluteCachePath(projectRoot, scratchPrefix, parsedGit.ref);
|
|
620
|
+
const prepErr = await ensureEmptyCache(scratchAbs, { replace: true });
|
|
621
|
+
if (prepErr) { stderr.write(`[error] blueprint library refresh: ${prepErr.message}\n`); return 2; }
|
|
622
|
+
const fetched = await fetchGitLibrary({ url: parsedGit.url, ref: parsedGit.ref, refKind: parsedGit.refKind, targetDir: scratchAbs });
|
|
623
|
+
if (isRcfError(fetched)) {
|
|
624
|
+
await removeCache(scratchAbs);
|
|
625
|
+
stderr.write(`[error] blueprint library refresh: ${fetched.message}\n`);
|
|
626
|
+
return 2;
|
|
627
|
+
}
|
|
628
|
+
const library = await loadLibrary(scratchAbs, { validateBlueprints: true });
|
|
629
|
+
if (isRcfError(library)) {
|
|
630
|
+
await removeCache(scratchAbs);
|
|
631
|
+
stderr.write(`[error] blueprint library refresh: ${library.message}\n`);
|
|
632
|
+
return 2;
|
|
633
|
+
}
|
|
634
|
+
const drifted = compareLibrarySnapshot(entry, library);
|
|
635
|
+
if (drifted.length > 0) {
|
|
636
|
+
await removeCache(scratchAbs);
|
|
637
|
+
stderr.write(`[blueprint library refresh] '${libraryPrefix}' has drifted from the registered snapshot:\n`);
|
|
638
|
+
for (const d of drifted) stderr.write(` ${d}\n`);
|
|
639
|
+
stderr.write("re-run 'rcf define blueprint library add <ref>' to pick up the newer library.\n");
|
|
640
|
+
return 3;
|
|
641
|
+
}
|
|
642
|
+
// Land the fresh tree in the registered cache slot.
|
|
643
|
+
const finalAbs = resolveCachePath(projectRoot, entry.cachePath);
|
|
644
|
+
const settleErr = await settleFinal(scratchAbs, finalAbs);
|
|
645
|
+
if (settleErr) { stderr.write(`[error] blueprint library refresh: ${settleErr.message}\n`); return 2; }
|
|
646
|
+
if (!parsed.values.quiet) {
|
|
647
|
+
stdout.write(`[blueprint library] '${libraryPrefix}' refresh clean: git ref '${parsedGit.ref}' still resolves to ${upstream.resolvedSha.slice(0, 12)}.\n`);
|
|
648
|
+
}
|
|
649
|
+
return 0;
|
|
381
650
|
}
|
|
382
|
-
|
|
651
|
+
if (entry.sourceKind === 'tarball') {
|
|
652
|
+
const expected = entry.provenance?.tarballSha256;
|
|
653
|
+
if (typeof expected !== 'string' || expected.length === 0) {
|
|
654
|
+
stderr.write("[error] blueprint library refresh: tarball entry has no provenance.tarballSha256 pin.\n");
|
|
655
|
+
return 2;
|
|
656
|
+
}
|
|
657
|
+
const scratchPrefix = `.pending-${process.pid}-${Date.now().toString(36)}`;
|
|
658
|
+
const scratchAbs = absoluteCachePath(projectRoot, scratchPrefix, 'refreshing');
|
|
659
|
+
const prepErr = await ensureEmptyCache(scratchAbs, { replace: true });
|
|
660
|
+
if (prepErr) { stderr.write(`[error] blueprint library refresh: ${prepErr.message}\n`); return 2; }
|
|
661
|
+
const fetched = await fetchTarballLibrary({ url: entry.sourceRef, expectedSha256: expected, targetDir: scratchAbs });
|
|
662
|
+
if (isRcfError(fetched)) {
|
|
663
|
+
await removeCache(scratchAbs);
|
|
664
|
+
stderr.write(`[error] blueprint library refresh: ${fetched.message}\n`);
|
|
665
|
+
return 2;
|
|
666
|
+
}
|
|
667
|
+
const library = await loadLibrary(scratchAbs, { validateBlueprints: true });
|
|
668
|
+
if (isRcfError(library)) {
|
|
669
|
+
await removeCache(scratchAbs);
|
|
670
|
+
stderr.write(`[error] blueprint library refresh: ${library.message}\n`);
|
|
671
|
+
return 2;
|
|
672
|
+
}
|
|
673
|
+
const drifted = compareLibrarySnapshot(entry, library);
|
|
674
|
+
if (drifted.length > 0) {
|
|
675
|
+
await removeCache(scratchAbs);
|
|
676
|
+
stderr.write(`[blueprint library refresh] '${libraryPrefix}' has drifted from the registered snapshot:\n`);
|
|
677
|
+
for (const d of drifted) stderr.write(` ${d}\n`);
|
|
678
|
+
stderr.write("re-run 'rcf define blueprint library add <ref>' to pick up the newer library.\n");
|
|
679
|
+
return 3;
|
|
680
|
+
}
|
|
681
|
+
const finalAbs = resolveCachePath(projectRoot, entry.cachePath);
|
|
682
|
+
const settleErr = await settleFinal(scratchAbs, finalAbs);
|
|
683
|
+
if (settleErr) { stderr.write(`[error] blueprint library refresh: ${settleErr.message}\n`); return 2; }
|
|
684
|
+
if (!parsed.values.quiet) {
|
|
685
|
+
stdout.write(`[blueprint library] '${libraryPrefix}' refresh clean: tarball SHA-256 still matches ${expected.slice(0, 12)}.\n`);
|
|
686
|
+
}
|
|
687
|
+
return 0;
|
|
688
|
+
}
|
|
689
|
+
stderr.write(`[error] blueprint library refresh: unknown source kind '${entry.sourceKind}'.\n`);
|
|
690
|
+
return 2;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function compareLibrarySnapshot(entry, library) {
|
|
383
694
|
const drifted = [];
|
|
384
695
|
if (library.libraryPrefix !== entry.libraryPrefix) drifted.push(`libraryPrefix '${entry.libraryPrefix}' -> '${library.libraryPrefix}'`);
|
|
385
696
|
if (library.libraryRef !== entry.libraryRef) drifted.push(`libraryRef '${entry.libraryRef}' -> '${library.libraryRef}'`);
|
|
386
697
|
if (JSON.stringify(library.bands) !== JSON.stringify(entry.bands)) drifted.push('bands changed');
|
|
387
|
-
|
|
388
|
-
stderr.write(`[blueprint library refresh] '${libraryPrefix}' has drifted from the registered snapshot:\n`);
|
|
389
|
-
for (const d of drifted) stderr.write(` ${d}\n`);
|
|
390
|
-
stderr.write("re-run 'rcf define blueprint library add <ref>' to pick up the newer library (spec §10.1: freshness is advisory, adoption is an operator act).\n");
|
|
391
|
-
return 3;
|
|
392
|
-
}
|
|
393
|
-
if (!parsed.values.quiet) {
|
|
394
|
-
stdout.write(`[blueprint library] '${libraryPrefix}' refresh clean: on-disk library matches the registry snapshot.\n`);
|
|
395
|
-
}
|
|
396
|
-
return 0;
|
|
698
|
+
return drifted;
|
|
397
699
|
}
|
|
398
700
|
|
|
399
701
|
function classifySourceRef(ref) {
|
|
400
|
-
|
|
401
|
-
|
|
702
|
+
// Tarball extension wins over the transport check because `https://.../foo.tar.gz`
|
|
703
|
+
// is a valid tarball ref and would otherwise be misclassified as git.
|
|
704
|
+
// Strip an optional `#` fragment before the extension check so a
|
|
705
|
+
// fragmented tarball URL still classifies correctly.
|
|
706
|
+
const noFragment = ref.split('#')[0];
|
|
707
|
+
if (noFragment.endsWith('.tar.gz') || noFragment.endsWith('.tgz') || noFragment.endsWith('.tar')) return 'tarball';
|
|
708
|
+
if (ref.startsWith('git+') || ref.startsWith('git@') || ref.startsWith('ssh://')) return 'git';
|
|
709
|
+
// http(s) with a `.git` in the path or a `#<ref>` fragment reads as
|
|
710
|
+
// a git URL; unadorned http(s) without either signal falls back to
|
|
711
|
+
// local so an accidental URL does not silently invoke a git clone.
|
|
712
|
+
if ((ref.startsWith('https://') || ref.startsWith('http://')) && (ref.includes('.git') || ref.includes('#'))) return 'git';
|
|
402
713
|
return 'local';
|
|
403
714
|
}
|
|
404
715
|
|
|
405
|
-
function printReview({ stdout, ref, library, libraryPrefix, coreReservations }) {
|
|
716
|
+
function printReview({ stdout, ref, library, libraryPrefix, coreReservations, sourceKind = 'local', resolvedSha, tarballSha256 }) {
|
|
406
717
|
const suffix = (library.bands.suffixBlocks ?? []).map((b) => `${b.kind} ${b.start}-${b.end}`).join(', ');
|
|
407
718
|
stdout.write(`\nREVIEW - you are about to add this library to the project registry.\n\n`);
|
|
408
719
|
stdout.write(` Library : ${library.displayName}\n`);
|
|
409
720
|
stdout.write(` Prefix : ${libraryPrefix}\n`);
|
|
410
721
|
stdout.write(` Publisher : ${library.publisher.displayName}${library.publisher.contact ? ` <${library.publisher.contact}>` : ''}\n`);
|
|
411
|
-
stdout.write(` Source : ${ref} (
|
|
722
|
+
stdout.write(` Source : ${ref} (${sourceKind})\n`);
|
|
723
|
+
if (typeof resolvedSha === 'string' && resolvedSha.length > 0) {
|
|
724
|
+
stdout.write(` Pinned sha : ${resolvedSha}\n`);
|
|
725
|
+
}
|
|
726
|
+
if (typeof tarballSha256 === 'string' && tarballSha256.length > 0) {
|
|
727
|
+
stdout.write(` Tarball sha : ${tarballSha256}\n`);
|
|
728
|
+
}
|
|
412
729
|
stdout.write(` Library ref : ${library.libraryRef}\n`);
|
|
413
730
|
stdout.write(` AC band : ${library.bands.ac.start} - ${library.bands.ac.end}\n`);
|
|
414
731
|
if (suffix.length > 0) stdout.write(` Suffix blocks: ${suffix}\n`);
|
|
@@ -430,7 +747,7 @@ function printReview({ stdout, ref, library, libraryPrefix, coreReservations })
|
|
|
430
747
|
stdout.write(` ${qualified.padEnd(qualifiedWidth)} -> ${bp.globalTopics.join(', ')}\n`);
|
|
431
748
|
}
|
|
432
749
|
}
|
|
433
|
-
stdout.write(`\n Provenance : local (dev use)\n`);
|
|
750
|
+
stdout.write(`\n Provenance : ${sourceKind}${sourceKind === 'local' ? ' (dev use)' : ''}\n`);
|
|
434
751
|
stdout.write(` Band check : cross-checked ${coreReservations.ac.length} core AC row(s), ${coreReservations.suffixBlocks.length} core suffix block(s); no overlap.\n`);
|
|
435
752
|
stdout.write(` Prefix check : '${libraryPrefix}' does not collide with any core slug.\n`);
|
|
436
753
|
stdout.write(`\n`);
|
package/src/cli/blueprint.js
CHANGED
|
@@ -115,6 +115,7 @@ const OPTION_SPEC = {
|
|
|
115
115
|
// library-verb flags (parsed centrally so strict:true doesn't refuse
|
|
116
116
|
// them when the operator types `rcf define blueprint library add`).
|
|
117
117
|
prefix: { type: 'string' },
|
|
118
|
+
sha256: { type: 'string' },
|
|
118
119
|
'i-have-reviewed': { type: 'boolean' },
|
|
119
120
|
'no-review': { type: 'boolean' },
|
|
120
121
|
json: { type: 'boolean' },
|