rcf-lite 0.14.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 +32 -0
- package/bin/view-supervisor-child.mjs +0 -0
- package/blueprints/delivery-ci-workflows/README.md +4 -0
- package/blueprints/delivery-ci-workflows/assets/bootstrap/README.md +26 -0
- package/blueprints/delivery-ci-workflows/assets/bootstrap/adr-bootstrap-coverage-supersession.template.json +28 -0
- package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/default-branch-checks.yml +12 -6
- package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/pull-request-checks.yml +16 -7
- package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/release.yml +4 -0
- package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/scheduled-audit.yml +4 -0
- package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/notes.md +5 -5
- package/blueprints/delivery-ci-workflows/assets/report-samples/per-gate.json +1 -1
- package/blueprints/delivery-ci-workflows/blueprint.json +1 -1
- package/blueprints/delivery-ci-workflows/contributions/adrs/adr-702-delivery-ci-workflows-strict-coverage-gate.json +2 -2
- package/blueprints/delivery-ci-workflows/contributions/tacs/tac-701-delivery-ci-workflows-gate-runner.json +2 -2
- package/blueprints/delivery-ci-workflows/contributions/tacs/tac-704-delivery-ci-workflows-workflow-materialiser.json +7 -4
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6111.json +2 -2
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6114.json +6 -6
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6115.json +6 -6
- package/blueprints/delivery-ci-workflows/guide/delivery-ci-workflows.md +39 -1
- 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 +13 -15
- package/releases/releases.yaml +21 -1
- package/src/blueprint/apply.js +15 -6
- 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/library-loader.js +21 -0
- package/src/blueprint/shelf-resolver.js +100 -7
- package/src/blueprint/supersede.js +56 -13
- package/src/cli/blueprint-library.js +427 -82
- package/src/cli/blueprint.js +6 -1
|
@@ -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)) {
|
|
@@ -313,13 +507,26 @@ async function handleRemove({ args, parsed, projectRoot, stdout, stderr }) {
|
|
|
313
507
|
return 2;
|
|
314
508
|
}
|
|
315
509
|
// Refuse when any applied blueprint on the project came through the
|
|
316
|
-
// library.
|
|
510
|
+
// library. The ownership fact lives on the record itself as
|
|
511
|
+
// `libraryPrefix` (stamped at apply-time when the apply resolved
|
|
512
|
+
// through this registry). Records applied before the field shipped
|
|
513
|
+
// (@stravica-ai/rcf-schemas 0.5.1) carry no `libraryPrefix`; for
|
|
514
|
+
// those we fall back to the pre-field signal, `source` starting with
|
|
515
|
+
// `<prefix>:`. Preferring the record over the string match makes the
|
|
516
|
+
// ownership durable across registry edits: a library re-registered
|
|
517
|
+
// under a different prefix does not orphan the records applied under
|
|
518
|
+
// the previous prefix.
|
|
317
519
|
const { tree, errors } = await walkTree({ projectRoot });
|
|
318
520
|
if (errors.length > 0) {
|
|
319
521
|
for (const e of errors) stderr.write(`[tree] ${e.kind}: ${e.message}\n`);
|
|
320
522
|
return 2;
|
|
321
523
|
}
|
|
322
|
-
const referring = (tree.manifest?.blueprints ?? []).filter((b) =>
|
|
524
|
+
const referring = (tree.manifest?.blueprints ?? []).filter((b) => {
|
|
525
|
+
if (typeof b.libraryPrefix === 'string' && b.libraryPrefix.length > 0) {
|
|
526
|
+
return b.libraryPrefix === libraryPrefix;
|
|
527
|
+
}
|
|
528
|
+
return typeof b.source === 'string' && b.source.startsWith(`${libraryPrefix}:`);
|
|
529
|
+
});
|
|
323
530
|
if (referring.length > 0) {
|
|
324
531
|
stderr.write(`[error] blueprint library remove: ${referring.length} applied blueprint(s) came through '${libraryPrefix}':\n`);
|
|
325
532
|
for (const r of referring) stderr.write(` ${r.slug} <- ${r.source}\n`);
|
|
@@ -330,11 +537,22 @@ async function handleRemove({ args, parsed, projectRoot, stdout, stderr }) {
|
|
|
330
537
|
registryVersion: registry.registryVersion,
|
|
331
538
|
libraries: registry.libraries.filter((l) => l.libraryPrefix !== libraryPrefix),
|
|
332
539
|
};
|
|
333
|
-
const
|
|
540
|
+
const dryRun = parsed.values['dry-run'] === true;
|
|
541
|
+
const write = await writeLibraryRegistry(projectRoot, nextRegistry, { dryRun });
|
|
334
542
|
if (isRcfError(write)) {
|
|
335
543
|
stderr.write(`[error] blueprint library remove: ${write.message}\n`);
|
|
336
544
|
return 2;
|
|
337
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
|
+
}
|
|
338
556
|
if (!parsed.values.quiet) {
|
|
339
557
|
stdout.write(`[blueprint library] removed '${libraryPrefix}' from ${write.path}.\n`);
|
|
340
558
|
}
|
|
@@ -357,45 +575,157 @@ async function handleRefresh({ args, parsed, projectRoot, stdout, stderr }) {
|
|
|
357
575
|
stderr.write(`[error] blueprint library refresh: library '${libraryPrefix}' is not registered.\n`);
|
|
358
576
|
return 2;
|
|
359
577
|
}
|
|
360
|
-
if (entry.sourceKind
|
|
361
|
-
|
|
362
|
-
|
|
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;
|
|
363
595
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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;
|
|
368
650
|
}
|
|
369
|
-
|
|
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) {
|
|
370
694
|
const drifted = [];
|
|
371
695
|
if (library.libraryPrefix !== entry.libraryPrefix) drifted.push(`libraryPrefix '${entry.libraryPrefix}' -> '${library.libraryPrefix}'`);
|
|
372
696
|
if (library.libraryRef !== entry.libraryRef) drifted.push(`libraryRef '${entry.libraryRef}' -> '${library.libraryRef}'`);
|
|
373
697
|
if (JSON.stringify(library.bands) !== JSON.stringify(entry.bands)) drifted.push('bands changed');
|
|
374
|
-
|
|
375
|
-
stderr.write(`[blueprint library refresh] '${libraryPrefix}' has drifted from the registered snapshot:\n`);
|
|
376
|
-
for (const d of drifted) stderr.write(` ${d}\n`);
|
|
377
|
-
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");
|
|
378
|
-
return 3;
|
|
379
|
-
}
|
|
380
|
-
if (!parsed.values.quiet) {
|
|
381
|
-
stdout.write(`[blueprint library] '${libraryPrefix}' refresh clean: on-disk library matches the registry snapshot.\n`);
|
|
382
|
-
}
|
|
383
|
-
return 0;
|
|
698
|
+
return drifted;
|
|
384
699
|
}
|
|
385
700
|
|
|
386
701
|
function classifySourceRef(ref) {
|
|
387
|
-
|
|
388
|
-
|
|
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';
|
|
389
713
|
return 'local';
|
|
390
714
|
}
|
|
391
715
|
|
|
392
|
-
function printReview({ stdout, ref, library, libraryPrefix, coreReservations }) {
|
|
716
|
+
function printReview({ stdout, ref, library, libraryPrefix, coreReservations, sourceKind = 'local', resolvedSha, tarballSha256 }) {
|
|
393
717
|
const suffix = (library.bands.suffixBlocks ?? []).map((b) => `${b.kind} ${b.start}-${b.end}`).join(', ');
|
|
394
718
|
stdout.write(`\nREVIEW - you are about to add this library to the project registry.\n\n`);
|
|
395
719
|
stdout.write(` Library : ${library.displayName}\n`);
|
|
396
720
|
stdout.write(` Prefix : ${libraryPrefix}\n`);
|
|
397
721
|
stdout.write(` Publisher : ${library.publisher.displayName}${library.publisher.contact ? ` <${library.publisher.contact}>` : ''}\n`);
|
|
398
|
-
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
|
+
}
|
|
399
729
|
stdout.write(` Library ref : ${library.libraryRef}\n`);
|
|
400
730
|
stdout.write(` AC band : ${library.bands.ac.start} - ${library.bands.ac.end}\n`);
|
|
401
731
|
if (suffix.length > 0) stdout.write(` Suffix blocks: ${suffix}\n`);
|
|
@@ -403,8 +733,23 @@ function printReview({ stdout, ref, library, libraryPrefix, coreReservations })
|
|
|
403
733
|
for (const bp of library.blueprints) {
|
|
404
734
|
stdout.write(` ${libraryPrefix}:${bp.slug}\n`);
|
|
405
735
|
}
|
|
406
|
-
|
|
736
|
+
// Spec §8.1: surface the scope:global ADR topics each blueprint
|
|
737
|
+
// claims so the operator sees, at the review moment, which cross-
|
|
738
|
+
// library / cross-core trust-boundary interactions this add commits
|
|
739
|
+
// them to. Suppressed only when no blueprint on the library claims
|
|
740
|
+
// any global topic (the render would otherwise be a lonely header).
|
|
741
|
+
const withTopics = library.blueprints.filter((bp) => Array.isArray(bp.globalTopics) && bp.globalTopics.length > 0);
|
|
742
|
+
if (withTopics.length > 0) {
|
|
743
|
+
const qualifiedWidth = Math.max(...withTopics.map((bp) => `${libraryPrefix}:${bp.slug}`.length));
|
|
744
|
+
stdout.write(`\n Global topics these blueprints claim (may conflict with core or with other libraries):\n`);
|
|
745
|
+
for (const bp of withTopics) {
|
|
746
|
+
const qualified = `${libraryPrefix}:${bp.slug}`;
|
|
747
|
+
stdout.write(` ${qualified.padEnd(qualifiedWidth)} -> ${bp.globalTopics.join(', ')}\n`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
stdout.write(`\n Provenance : ${sourceKind}${sourceKind === 'local' ? ' (dev use)' : ''}\n`);
|
|
407
751
|
stdout.write(` Band check : cross-checked ${coreReservations.ac.length} core AC row(s), ${coreReservations.suffixBlocks.length} core suffix block(s); no overlap.\n`);
|
|
752
|
+
stdout.write(` Prefix check : '${libraryPrefix}' does not collide with any core slug.\n`);
|
|
408
753
|
stdout.write(`\n`);
|
|
409
754
|
}
|
|
410
755
|
|