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
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// Git fetcher for external blueprint libraries (Phase 2c, spec §6.2).
|
|
2
|
+
//
|
|
3
|
+
// Supports two pin forms: a 40-character commit sha, or an annotated
|
|
4
|
+
// tag. Lightweight tags, branch names, HEAD and `latest` are refused
|
|
5
|
+
// categorically at classification time (spec §6.1) - a floating ref is
|
|
6
|
+
// a supply-chain hazard the pinning discipline exists to catch.
|
|
7
|
+
//
|
|
8
|
+
// Transport: the ambient `git` command-line tool via `execFile`. No
|
|
9
|
+
// libgit2 dependency; per spec §6.2 auth surface is `none` in v1, so
|
|
10
|
+
// private-repo libraries must be mirrored to a local clone the
|
|
11
|
+
// operator has access to (spec §9.4 ratified: ambient git access is
|
|
12
|
+
// the model, full stop; no-access is definitive).
|
|
13
|
+
|
|
14
|
+
import { execFile } from 'node:child_process';
|
|
15
|
+
import { promisify } from 'node:util';
|
|
16
|
+
import { readdir, rename, rm } from 'node:fs/promises';
|
|
17
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { rcfError } from '../core/errors/index.js';
|
|
22
|
+
|
|
23
|
+
const execFileAsync = promisify(execFile);
|
|
24
|
+
|
|
25
|
+
const SHA_FULL = /^[0-9a-f]{40}$/i;
|
|
26
|
+
// Tag shape: kebab or dotted, permissive because publishers pick their
|
|
27
|
+
// own tag conventions. We refuse `HEAD` / `latest` and known branch
|
|
28
|
+
// aliases explicitly; anything else is treated as a tag candidate and
|
|
29
|
+
// checked for annotation post-fetch.
|
|
30
|
+
const REFUSED_REFS = new Set(['HEAD', 'head', 'FETCH_HEAD', 'MERGE_HEAD', 'ORIG_HEAD', 'latest', 'main', 'master', 'trunk', 'develop']);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse a `git+<url>#<ref>` or `<url>#<ref>` source ref into its
|
|
34
|
+
* components. The `git+` scheme prefix is stripped for the underlying
|
|
35
|
+
* `git` command (which does not understand it); the fragment `#<ref>`
|
|
36
|
+
* becomes the pin. Refuses missing fragment (spec §6.1: pin discipline
|
|
37
|
+
* is mandatory, floating refs are refused).
|
|
38
|
+
*
|
|
39
|
+
* @param {string} sourceRef
|
|
40
|
+
* @returns {{ url: string, ref: string, refKind: 'sha' | 'tag' } | import('../core/errors/index.js').RcfError}
|
|
41
|
+
*/
|
|
42
|
+
export function parseGitRef(sourceRef) {
|
|
43
|
+
if (typeof sourceRef !== 'string' || sourceRef.length === 0) {
|
|
44
|
+
return rcfError({ kind: 'usage', message: 'git library ref: source is empty' });
|
|
45
|
+
}
|
|
46
|
+
const hashIdx = sourceRef.lastIndexOf('#');
|
|
47
|
+
if (hashIdx < 0) {
|
|
48
|
+
return rcfError({
|
|
49
|
+
kind: 'usage',
|
|
50
|
+
message: `git library ref '${sourceRef}': missing '#<ref>' pin. Every git library pins to a commit sha or an annotated tag (spec §6.1); floating branches are refused.`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const rawUrl = sourceRef.slice(0, hashIdx);
|
|
54
|
+
const ref = sourceRef.slice(hashIdx + 1);
|
|
55
|
+
if (ref.length === 0) {
|
|
56
|
+
return rcfError({ kind: 'usage', message: `git library ref '${sourceRef}': empty ref after '#'.` });
|
|
57
|
+
}
|
|
58
|
+
const url = rawUrl.startsWith('git+') ? rawUrl.slice(4) : rawUrl;
|
|
59
|
+
if (url.length === 0) {
|
|
60
|
+
return rcfError({ kind: 'usage', message: `git library ref '${sourceRef}': empty URL before '#'.` });
|
|
61
|
+
}
|
|
62
|
+
if (REFUSED_REFS.has(ref)) {
|
|
63
|
+
return rcfError({
|
|
64
|
+
kind: 'usage',
|
|
65
|
+
message: `git library ref '${sourceRef}': ref '${ref}' is a floating branch or alias; pin to an annotated tag or a commit sha.`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const refKind = SHA_FULL.test(ref) ? 'sha' : 'tag';
|
|
69
|
+
return { url, ref, refKind };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fetch a git library at a pinned ref into a target directory. The
|
|
74
|
+
* target must be empty (the caller uses `library-cache.ensureEmptyCache`
|
|
75
|
+
* first). Returns the resolved commit sha and the library root, or an
|
|
76
|
+
* RcfError. Rolls back a partial fetch on failure.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} args
|
|
79
|
+
* @param {string} args.url git URL (post `git+` strip)
|
|
80
|
+
* @param {string} args.ref sha or tag
|
|
81
|
+
* @param {'sha' | 'tag'} args.refKind
|
|
82
|
+
* @param {string} args.targetDir absolute path to place the checkout
|
|
83
|
+
* @param {string} [args.git='git'] override for tests
|
|
84
|
+
* @param {number} [args.timeoutMs=120000] per-invocation timeout for git
|
|
85
|
+
* @returns {Promise<{ resolvedSha: string, root: string } | import('../core/errors/index.js').RcfError>}
|
|
86
|
+
*/
|
|
87
|
+
export async function fetchGitLibrary({ url, ref, refKind, targetDir, git = 'git', timeoutMs = 120000 }) {
|
|
88
|
+
// Fetch into a sibling temp dir first, then rename onto targetDir on
|
|
89
|
+
// success. This keeps `targetDir` empty until the fetch is verified,
|
|
90
|
+
// and gives a clean rollback path on any git error.
|
|
91
|
+
let scratch;
|
|
92
|
+
try {
|
|
93
|
+
scratch = await mkdtemp(join(tmpdir(), 'rcf-lib-git-'));
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return rcfError({ kind: 'ioFailure', message: `git fetch: scratch dir: ${err.message}`, stack: err.stack });
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
// Two runners: `runOutside` for the initial clone (has no working
|
|
99
|
+
// directory yet), `runInside` for every follow-up query against the
|
|
100
|
+
// clone in `scratch`. Splitting them keeps a subtle bug at bay:
|
|
101
|
+
// querying `refs/tags/<t>` from process.cwd() when the CLI happens
|
|
102
|
+
// to be invoked inside another git repo can either succeed (against
|
|
103
|
+
// the WRONG repo) or fail spuriously; scoping the query to the
|
|
104
|
+
// scratch clone is the only correct answer.
|
|
105
|
+
const runOutside = (args) => execFileAsync(git, args, { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
|
|
106
|
+
const runInside = (args) => execFileAsync(git, args, { cwd: scratch, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
|
|
107
|
+
|
|
108
|
+
if (refKind === 'tag') {
|
|
109
|
+
// Shallow clone at the tag. `--no-tags` prevents pulling every
|
|
110
|
+
// OTHER tag object; the target tag object still arrives because
|
|
111
|
+
// `--branch <tag>` fetches it explicitly.
|
|
112
|
+
try {
|
|
113
|
+
await runOutside(['clone', '--depth', '1', '--branch', ref, '--no-tags', url, scratch]);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return rcfError({
|
|
116
|
+
kind: 'usage',
|
|
117
|
+
message: `git fetch: clone --branch ${ref} from ${url} failed: ${cleanGitStderr(err)}`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
// Verify annotation. Annotated tags produce a tag OBJECT
|
|
121
|
+
// (`for-each-ref refs/tags/<t> %(objecttype)` returns 'tag');
|
|
122
|
+
// lightweight tags point straight at a commit and return
|
|
123
|
+
// 'commit'. The spec pin discipline (§6.1) requires annotation.
|
|
124
|
+
// `for-each-ref` is preferred over `cat-file -t` because it
|
|
125
|
+
// never touches a working-tree HEAD state; it queries the ref
|
|
126
|
+
// record directly.
|
|
127
|
+
const objType = await runGitLine(runInside, ['for-each-ref', `refs/tags/${ref}`, '--format=%(objecttype)']);
|
|
128
|
+
if (typeof objType !== 'string') return objType;
|
|
129
|
+
const objTypeTrim = objType.trim();
|
|
130
|
+
if (objTypeTrim.length === 0) {
|
|
131
|
+
return rcfError({
|
|
132
|
+
kind: 'usage',
|
|
133
|
+
message: `git fetch: ref '${ref}' at ${url} did not land a tag record in the clone; check that the ref names a tag (annotated) or pin to a sha.`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (objTypeTrim !== 'tag') {
|
|
137
|
+
return rcfError({
|
|
138
|
+
kind: 'usage',
|
|
139
|
+
message: `git fetch: ref '${ref}' at ${url} is a lightweight tag (or a branch alias). Publishers must ship annotated tags so the pin has a tamper-evident object; re-tag upstream, or pin to a sha.`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const sha = await runGitLine(runInside, ['for-each-ref', `refs/tags/${ref}`, '--format=%(*objectname)']);
|
|
143
|
+
if (typeof sha !== 'string') return sha;
|
|
144
|
+
const resolvedSha = sha.trim();
|
|
145
|
+
if (!SHA_FULL.test(resolvedSha)) {
|
|
146
|
+
return rcfError({ kind: 'usage', message: `git fetch: for-each-ref produced non-sha output '${resolvedSha}'` });
|
|
147
|
+
}
|
|
148
|
+
await stripDotGit(scratch);
|
|
149
|
+
const settled = await settleCache(scratch, targetDir);
|
|
150
|
+
if (settled) return settled;
|
|
151
|
+
return { resolvedSha, root: targetDir };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// sha ref: full history (a shallow clone cannot land on an arbitrary
|
|
155
|
+
// sha unless the server advertises it via `uploadpack.allowReachableSHA1InWant`;
|
|
156
|
+
// full clone is the portable option). We disable checkout so the
|
|
157
|
+
// sha resolve and the switch happen after the fetch settled.
|
|
158
|
+
try {
|
|
159
|
+
await runOutside(['clone', '--no-checkout', '--no-tags', url, scratch]);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
return rcfError({
|
|
162
|
+
kind: 'usage',
|
|
163
|
+
message: `git fetch: clone from ${url} failed: ${cleanGitStderr(err)}`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
// Verify the sha exists AND names a commit (not a tree, blob or
|
|
167
|
+
// annotated-tag object). `cat-file -e` fails on unknown objects.
|
|
168
|
+
try {
|
|
169
|
+
await runInside(['cat-file', '-e', ref]);
|
|
170
|
+
} catch {
|
|
171
|
+
return rcfError({
|
|
172
|
+
kind: 'usage',
|
|
173
|
+
message: `git fetch: commit sha '${ref}' is not reachable in ${url}. Check the sha, or ask the publisher to keep the ref in their history.`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const commitType = await runGitLine(runInside, ['cat-file', '-t', ref]);
|
|
177
|
+
if (typeof commitType !== 'string') return commitType;
|
|
178
|
+
if (commitType.trim() !== 'commit') {
|
|
179
|
+
return rcfError({
|
|
180
|
+
kind: 'usage',
|
|
181
|
+
message: `git fetch: ref '${ref}' names a git object of type '${commitType.trim()}', not a commit.`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
await runInside(['-c', 'advice.detachedHead=false', 'checkout', ref]);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
return rcfError({ kind: 'usage', message: `git fetch: checkout ${ref} failed: ${cleanGitStderr(err)}` });
|
|
188
|
+
}
|
|
189
|
+
await stripDotGit(scratch);
|
|
190
|
+
const settled = await settleCache(scratch, targetDir);
|
|
191
|
+
if (settled) return settled;
|
|
192
|
+
return { resolvedSha: ref.toLowerCase(), root: targetDir };
|
|
193
|
+
} catch (err) {
|
|
194
|
+
await rm(scratch, { recursive: true, force: true }).catch(() => {});
|
|
195
|
+
return rcfError({ kind: 'ioFailure', message: `git fetch: ${err.message}`, stack: err.stack });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Re-resolve the current commit sha of a tag at a remote without
|
|
201
|
+
* cloning. Used by `library refresh` on git sources to detect an
|
|
202
|
+
* annotated-tag move (spec §6.4 / §9.12: refuse on drift, never rewrite
|
|
203
|
+
* the pin).
|
|
204
|
+
*
|
|
205
|
+
* @param {object} args
|
|
206
|
+
* @param {string} args.url
|
|
207
|
+
* @param {string} args.ref
|
|
208
|
+
* @param {'sha' | 'tag'} args.refKind
|
|
209
|
+
* @param {string} [args.git='git']
|
|
210
|
+
* @param {number} [args.timeoutMs=60000]
|
|
211
|
+
* @returns {Promise<{ resolvedSha: string } | import('../core/errors/index.js').RcfError>}
|
|
212
|
+
*/
|
|
213
|
+
export async function resolveRemoteSha({ url, ref, refKind, git = 'git', timeoutMs = 60000 }) {
|
|
214
|
+
if (refKind === 'sha') {
|
|
215
|
+
// Pinned to a commit sha; the sha is its own identity, nothing to
|
|
216
|
+
// re-resolve upstream. Refresh callers already have this value in
|
|
217
|
+
// the registry.
|
|
218
|
+
return { resolvedSha: ref.toLowerCase() };
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
// `ls-remote` prints one line per matching ref: `<sha>\t<name>`.
|
|
222
|
+
// For an ANNOTATED tag the output has two rows:
|
|
223
|
+
// <tag-object-sha> refs/tags/<t>
|
|
224
|
+
// <peeled-commit-sha> refs/tags/<t>^{}
|
|
225
|
+
// We take the peeled row (the commit the tag points at); if no
|
|
226
|
+
// peeled row appears the tag is lightweight and we refuse for the
|
|
227
|
+
// same reason as the fetch path (spec §6.1).
|
|
228
|
+
//
|
|
229
|
+
// Ordering trap: `git ls-remote --tags <url> <pattern>` filters out
|
|
230
|
+
// peeled rows. Without the pattern arg (or with `--tags` plus a
|
|
231
|
+
// trailing pattern targeting the full ref path), git happily
|
|
232
|
+
// returns both. We list all tags and grep client-side so the peeled
|
|
233
|
+
// row survives; this is a small fixed cost (tag namespaces are
|
|
234
|
+
// tiny) and gives us a portable primitive.
|
|
235
|
+
const { stdout } = await execFileAsync(git, ['ls-remote', '--tags', url], {
|
|
236
|
+
encoding: 'utf8', timeout: timeoutMs,
|
|
237
|
+
});
|
|
238
|
+
const lines = stdout.split('\n').filter((l) => l.length > 0);
|
|
239
|
+
const targetName = `refs/tags/${ref}`;
|
|
240
|
+
const peeled = lines.find((l) => l.endsWith(`${targetName}^{}`));
|
|
241
|
+
if (peeled) {
|
|
242
|
+
const sha = peeled.split(/\s+/)[0];
|
|
243
|
+
if (!SHA_FULL.test(sha)) {
|
|
244
|
+
return rcfError({ kind: 'usage', message: `git resolve: ls-remote returned non-sha '${sha}'` });
|
|
245
|
+
}
|
|
246
|
+
return { resolvedSha: sha.toLowerCase() };
|
|
247
|
+
}
|
|
248
|
+
const plain = lines.find((l) => l.endsWith(targetName));
|
|
249
|
+
if (plain) {
|
|
250
|
+
return rcfError({
|
|
251
|
+
kind: 'usage',
|
|
252
|
+
message: `git resolve: tag '${ref}' at ${url} is lightweight (no peeled row); pin discipline requires an annotated tag or a sha.`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return rcfError({
|
|
256
|
+
kind: 'usage',
|
|
257
|
+
message: `git resolve: no tag '${ref}' found at ${url}. The publisher may have removed it.`,
|
|
258
|
+
});
|
|
259
|
+
} catch (err) {
|
|
260
|
+
return rcfError({ kind: 'usage', message: `git resolve: ls-remote ${url} ${ref} failed: ${cleanGitStderr(err)}` });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Remove the .git dir from a fetched clone so the resulting cache is
|
|
266
|
+
* ordinary tree content the consuming project can `git add` under its
|
|
267
|
+
* own repo without git treating the nested `.git` as a gitlink and
|
|
268
|
+
* refusing the add. The fetch has already recorded the sha in the
|
|
269
|
+
* caller's return value; the pinned identity survives the strip.
|
|
270
|
+
*/
|
|
271
|
+
async function stripDotGit(root) {
|
|
272
|
+
try {
|
|
273
|
+
await rm(join(root, '.git'), { recursive: true, force: true });
|
|
274
|
+
} catch {
|
|
275
|
+
// A missing .git is fine; a rm failure is not load-bearing enough
|
|
276
|
+
// to fail the whole fetch, and the settle step will surface any
|
|
277
|
+
// real IO trouble.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function runGitLine(runner, args) {
|
|
282
|
+
try {
|
|
283
|
+
const { stdout } = await runner(args);
|
|
284
|
+
return stdout;
|
|
285
|
+
} catch (err) {
|
|
286
|
+
return rcfError({ kind: 'usage', message: `git ${args.join(' ')}: ${cleanGitStderr(err)}` });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function settleCache(scratch, targetDir) {
|
|
291
|
+
// The library root is `scratch`; we rename it INTO the targetDir. On
|
|
292
|
+
// POSIX rename is atomic when both paths sit on the same filesystem;
|
|
293
|
+
// when they do not, fall back to a directory-tree copy. `ensureEmptyCache`
|
|
294
|
+
// in the caller has already created targetDir; we replace its dir
|
|
295
|
+
// entry with the scratch tree by renaming scratch onto it after a
|
|
296
|
+
// preparatory removal.
|
|
297
|
+
try {
|
|
298
|
+
await rm(targetDir, { recursive: true, force: true });
|
|
299
|
+
await rename(scratch, targetDir);
|
|
300
|
+
return null;
|
|
301
|
+
} catch (err) {
|
|
302
|
+
if (err.code === 'EXDEV') {
|
|
303
|
+
// Cross-device rename; copy tree and clean up scratch.
|
|
304
|
+
try {
|
|
305
|
+
const { cp } = await import('node:fs/promises');
|
|
306
|
+
await cp(scratch, targetDir, { recursive: true });
|
|
307
|
+
await rm(scratch, { recursive: true, force: true }).catch(() => {});
|
|
308
|
+
return null;
|
|
309
|
+
} catch (copyErr) {
|
|
310
|
+
return rcfError({ kind: 'ioFailure', message: `git fetch: cross-device settle failed: ${copyErr.message}`, stack: copyErr.stack });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return rcfError({ kind: 'ioFailure', message: `git fetch: settle failed: ${err.message}`, stack: err.stack });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Trim the noisy `Command failed:` header and any newline flotsam off a
|
|
319
|
+
* child-process error so the diagnostic reads clean at the CLI edge.
|
|
320
|
+
*
|
|
321
|
+
* @param {any} err
|
|
322
|
+
* @returns {string}
|
|
323
|
+
*/
|
|
324
|
+
function cleanGitStderr(err) {
|
|
325
|
+
const parts = [];
|
|
326
|
+
const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : '';
|
|
327
|
+
if (stderr.length > 0) parts.push(stderr);
|
|
328
|
+
else if (typeof err?.message === 'string') parts.push(err.message.replace(/^Command failed:.*$/m, '').trim());
|
|
329
|
+
const cleaned = parts.filter(Boolean).join('; ');
|
|
330
|
+
return cleaned.length > 0 ? cleaned : 'git exited with a non-zero status';
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Read-only. Test-visible so unit tests can assert. */
|
|
334
|
+
export function isFullSha(s) {
|
|
335
|
+
return typeof s === 'string' && SHA_FULL.test(s);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Refused branch/alias names, test-visible. */
|
|
339
|
+
export function refusedRefs() {
|
|
340
|
+
return new Set(REFUSED_REFS);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Utility read of the on-disk cache contents; useful for callers that
|
|
344
|
+
// want to enumerate the extracted tree without importing node:fs.
|
|
345
|
+
export async function listCacheEntries(dir) {
|
|
346
|
+
return readdir(dir);
|
|
347
|
+
}
|