rcf-lite 0.15.0 → 0.17.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/blueprints/application-spa/assets/tc-templates/e2e.md +85 -0
  3. package/blueprints/application-spa/blueprint.json +24 -2
  4. package/blueprints/application-spa/contributions/user-stories/application-spa-us-1134.json +24 -0
  5. package/blueprints/application-spa/contributions/user-stories/application-spa-us-1135.json +24 -0
  6. package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/pull-request-checks.yml +69 -0
  7. package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/notes.md +18 -0
  8. package/blueprints/delivery-ci-workflows/blueprint.json +301 -60
  9. package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6124.json +28 -0
  10. package/fixtures/canary-manifest.json +101 -1
  11. package/guidance/harness-template.md +9 -0
  12. package/guidance/managed/agent-instructions-block.hash +1 -1
  13. package/guidance/managed/agent-instructions-block.md +9 -0
  14. package/package.json +13 -1
  15. package/rcf/code-nodes/cn-070.json +12 -0
  16. package/rcf/code-nodes/cn-071.json +12 -0
  17. package/rcf/code-nodes/cn-072.json +12 -0
  18. package/rcf/code-nodes/cn-073.json +12 -0
  19. package/rcf/fbs/fbs-020.json +18 -0
  20. package/rcf/fbs/fbs-021.json +18 -0
  21. package/rcf/fbs/fbs-022.json +18 -0
  22. package/rcf/fbs/fbs-023.json +18 -0
  23. package/rcf/prd.json +3 -2
  24. package/rcf/requirements/req-011.json +22 -0
  25. package/rcf/test-suites/ts-030.json +66 -0
  26. package/rcf/test-suites/ts-031.json +59 -0
  27. package/rcf/test-suites/ts-032.json +50 -0
  28. package/rcf/test-suites/ts-033.json +83 -0
  29. package/rcf/user-stories/us-1101.json +51 -0
  30. package/rcf/user-stories/us-1102.json +60 -0
  31. package/rcf/user-stories/us-1103.json +51 -0
  32. package/rcf/user-stories/us-1104.json +60 -0
  33. package/releases/releases.yaml +21 -1
  34. package/src/blueprint/index.js +22 -0
  35. package/src/blueprint/library-cache.js +143 -0
  36. package/src/blueprint/library-fetcher-git.js +347 -0
  37. package/src/blueprint/library-fetcher-tarball.js +379 -0
  38. package/src/blueprint/shelf-resolver.js +100 -7
  39. package/src/blueprint/supersede.js +2 -2
  40. package/src/cli/blueprint-library.js +397 -80
  41. package/src/cli/blueprint.js +1 -0
  42. package/src/cli/doctor.js +182 -5
  43. package/src/cli/init.js +166 -0
  44. package/src/setup/playwright-checks.js +426 -0
  45. package/src/verify/cli/run.js +28 -0
  46. package/src/verify/engine/index.js +24 -4
  47. package/src/verify/engine/launcher.js +41 -10
@@ -40,8 +40,28 @@
40
40
  # `npm install rcf-lite`.
41
41
 
42
42
  feedVersion: 1
43
- latest: "0.15.0"
43
+ latest: "0.17.0"
44
44
  releases:
45
+ - version: "0.17.0"
46
+ date: "2026-09-04"
47
+ breaking: false
48
+ headlines:
49
+ - "rcf verify now pins the Playwright MCP browser tooling to a specific version, prints the effective pin on preflight and lands it on the run report, and takes a one-run override for emergency use."
50
+ - "rcf doctor grows four checks for browser-facing projects (Playwright installed, a browser available, the Playwright MCP reachable, and no shadowed duplicate entry), routed by an applied blueprint declaring a browser surface; API-only projects see one skip line."
51
+ - "rcf init writes a Playwright MCP entry only when no scope-visible one exists; when it cannot prove absence it writes a distinctly named playwright-rcf project-scope entry, and --no-playwright-mcp opts out entirely. application-spa v1.4.0 and delivery-ci-workflows v2.2.0 ship the paired browser stories and the illustrative e2e job as a documentation contract."
52
+ minAgentAction: null
53
+ notesUrl: "https://stravica.ai/docs/rcf/changelog/"
54
+
55
+ - version: "0.16.0"
56
+ date: "2026-09-03"
57
+ breaking: false
58
+ headlines:
59
+ - "External blueprint libraries now support git and tarball sources with an on-disk cache checked into the tree, plus a refresh command that re-resolves a library's pinned reference and refuses the update on drift."
60
+ - "Adding a blueprint from a local path inside an unregistered library now stamps the same slug and identity a registered add would, closing the author-and-test-locally loop."
61
+ - "The managed agent-instructions block now teaches the agent to offer blueprints and registered libraries in plain words so the operator picks; a new library authoring standard doc and worked-example fixture ship with the package. Rerun rcf init to refresh the block."
62
+ minAgentAction: "rerun-init"
63
+ notesUrl: "https://stravica.ai/docs/rcf/changelog/"
64
+
45
65
  - version: "0.15.0"
46
66
  date: "2026-08-31"
47
67
  breaking: false
@@ -23,3 +23,25 @@ export {
23
23
  detectContributionsOutOfBand,
24
24
  detectPrefixCollision,
25
25
  } from './library-registry.js';
26
+ export {
27
+ CACHE_ROOT,
28
+ absoluteCachePath,
29
+ ensureEmptyCache,
30
+ relativeCachePath,
31
+ removeCache,
32
+ resolveCachePath,
33
+ sanitiseRef,
34
+ } from './library-cache.js';
35
+ export {
36
+ fetchGitLibrary,
37
+ parseGitRef,
38
+ resolveRemoteSha,
39
+ isFullSha,
40
+ refusedRefs,
41
+ } from './library-fetcher-git.js';
42
+ export {
43
+ createUstarBuffer,
44
+ fetchTarballLibrary,
45
+ parseUstar,
46
+ sha256Hex,
47
+ } from './library-fetcher-tarball.js';
@@ -0,0 +1,143 @@
1
+ // External-library on-disk cache helpers (spec §4.4).
2
+ //
3
+ // Fetched library content sits under
4
+ // `rcf/.blueprint-libraries/<libraryPrefix>/<libraryRef>/`, checked
5
+ // into git as ordinary tree content so a fresh clone can `rcf define
6
+ // blueprint list` without a re-fetch. The cache is the working root
7
+ // the resolver reads through; the `cachePath` field on every registry
8
+ // entry points at it.
9
+ //
10
+ // This module owns path computation and a small set of directory
11
+ // primitives shared by the git and tarball fetchers (Phase 2c). It
12
+ // deliberately does NOT know about git or tar; the fetchers layer on
13
+ // top and land their extracted content at the paths computed here.
14
+
15
+ import { existsSync } from 'node:fs';
16
+ import { mkdir, rm, stat } from 'node:fs/promises';
17
+ import { isAbsolute, join, resolve } from 'node:path';
18
+
19
+ import { rcfError } from '../core/errors/index.js';
20
+
21
+ export const CACHE_ROOT = 'rcf/.blueprint-libraries';
22
+
23
+ /**
24
+ * Repo-relative cache path for a library at a given ref. Repo-relative
25
+ * because that is what the registry stores (`entry.cachePath`); the
26
+ * resolver joins it against `projectRoot` at read time.
27
+ *
28
+ * @param {string} libraryPrefix
29
+ * @param {string} libraryRef
30
+ * @returns {string}
31
+ */
32
+ export function relativeCachePath(libraryPrefix, libraryRef) {
33
+ return `${CACHE_ROOT}/${libraryPrefix}/${sanitiseRef(libraryRef)}`;
34
+ }
35
+
36
+ /**
37
+ * Absolute cache path for a library at a given ref, joined against a
38
+ * project root.
39
+ *
40
+ * @param {string} projectRoot
41
+ * @param {string} libraryPrefix
42
+ * @param {string} libraryRef
43
+ * @returns {string}
44
+ */
45
+ export function absoluteCachePath(projectRoot, libraryPrefix, libraryRef) {
46
+ const rel = relativeCachePath(libraryPrefix, libraryRef);
47
+ return isAbsolute(rel) ? rel : join(projectRoot, rel);
48
+ }
49
+
50
+ /**
51
+ * Prepare an empty cache directory. Refuses when a non-empty cache
52
+ * already exists at the target (the caller must remove it first via
53
+ * `removeCache`, or fail fast and prompt the operator). This keeps
54
+ * fetches deterministic: content lands on a clean slate every time.
55
+ *
56
+ * @param {string} absPath
57
+ * @param {object} [opts]
58
+ * @param {boolean} [opts.replace=false] - when true, remove any
59
+ * pre-existing content at absPath before creating the fresh dir.
60
+ * @returns {Promise<null | import('../core/errors/index.js').RcfError>}
61
+ */
62
+ export async function ensureEmptyCache(absPath, opts = {}) {
63
+ try {
64
+ if (existsSync(absPath)) {
65
+ if (opts.replace === true) {
66
+ await rm(absPath, { recursive: true, force: true });
67
+ } else {
68
+ const s = await stat(absPath);
69
+ if (s.isDirectory()) {
70
+ return rcfError({
71
+ kind: 'usage',
72
+ message: `library cache: path already exists at ${absPath}. Remove it or run 'library refresh' to re-fetch.`,
73
+ filePath: absPath,
74
+ });
75
+ }
76
+ return rcfError({
77
+ kind: 'usage',
78
+ message: `library cache: non-directory blocks cache path ${absPath}.`,
79
+ filePath: absPath,
80
+ });
81
+ }
82
+ }
83
+ await mkdir(absPath, { recursive: true });
84
+ return null;
85
+ } catch (err) {
86
+ return rcfError({
87
+ kind: 'ioFailure',
88
+ message: `library cache: could not prepare ${absPath}: ${err.message}`,
89
+ filePath: absPath,
90
+ stack: err.stack,
91
+ });
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Recursive remove of a cache directory. No-op when the path does not
97
+ * exist. Used by `library remove` and by the fetchers' rollback path.
98
+ *
99
+ * @param {string} absPath
100
+ * @returns {Promise<null | import('../core/errors/index.js').RcfError>}
101
+ */
102
+ export async function removeCache(absPath) {
103
+ try {
104
+ await rm(absPath, { recursive: true, force: true });
105
+ return null;
106
+ } catch (err) {
107
+ return rcfError({
108
+ kind: 'ioFailure',
109
+ message: `library cache: could not remove ${absPath}: ${err.message}`,
110
+ filePath: absPath,
111
+ stack: err.stack,
112
+ });
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Resolve an absolute cache path from either an absolute
118
+ * `entry.cachePath` (legacy local-source entries in phase 2b stored
119
+ * the library root path here) or a repo-relative one (phase 2c network
120
+ * fetches store the relative form; the resolver joins with the project
121
+ * root at read time).
122
+ *
123
+ * @param {string} projectRoot
124
+ * @param {string} cachePath
125
+ * @returns {string}
126
+ */
127
+ export function resolveCachePath(projectRoot, cachePath) {
128
+ return isAbsolute(cachePath) ? cachePath : resolve(projectRoot, cachePath);
129
+ }
130
+
131
+ /**
132
+ * Replace path-hostile characters in a libraryRef so it can be used as
133
+ * a directory segment. Refs are already semver-ish or short tag names
134
+ * in practice; this guards against a publisher who ships a ref like
135
+ * `1.2.0/rc1` or an operator who hand-types one. Slashes and colons are
136
+ * mapped to a single `-` so the on-disk layout stays flat.
137
+ *
138
+ * @param {string} ref
139
+ * @returns {string}
140
+ */
141
+ export function sanitiseRef(ref) {
142
+ return String(ref).replace(/[\\/:*?"<>|]+/g, '-');
143
+ }
@@ -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
+ }