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.
- package/CHANGELOG.md +27 -0
- package/blueprints/application-spa/assets/tc-templates/e2e.md +85 -0
- package/blueprints/application-spa/blueprint.json +24 -2
- package/blueprints/application-spa/contributions/user-stories/application-spa-us-1134.json +24 -0
- package/blueprints/application-spa/contributions/user-stories/application-spa-us-1135.json +24 -0
- package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/pull-request-checks.yml +69 -0
- package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/notes.md +18 -0
- package/blueprints/delivery-ci-workflows/blueprint.json +301 -60
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6124.json +28 -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 +13 -1
- package/rcf/code-nodes/cn-070.json +12 -0
- package/rcf/code-nodes/cn-071.json +12 -0
- package/rcf/code-nodes/cn-072.json +12 -0
- package/rcf/code-nodes/cn-073.json +12 -0
- package/rcf/fbs/fbs-020.json +18 -0
- package/rcf/fbs/fbs-021.json +18 -0
- package/rcf/fbs/fbs-022.json +18 -0
- package/rcf/fbs/fbs-023.json +18 -0
- package/rcf/prd.json +3 -2
- package/rcf/requirements/req-011.json +22 -0
- package/rcf/test-suites/ts-030.json +66 -0
- package/rcf/test-suites/ts-031.json +59 -0
- package/rcf/test-suites/ts-032.json +50 -0
- package/rcf/test-suites/ts-033.json +83 -0
- package/rcf/user-stories/us-1101.json +51 -0
- package/rcf/user-stories/us-1102.json +60 -0
- package/rcf/user-stories/us-1103.json +51 -0
- package/rcf/user-stories/us-1104.json +60 -0
- package/releases/releases.yaml +21 -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/blueprint/supersede.js +2 -2
- package/src/cli/blueprint-library.js +397 -80
- package/src/cli/blueprint.js +1 -0
- package/src/cli/doctor.js +182 -5
- package/src/cli/init.js +166 -0
- package/src/setup/playwright-checks.js +426 -0
- package/src/verify/cli/run.js +28 -0
- package/src/verify/engine/index.js +24 -4
- package/src/verify/engine/launcher.js +41 -10
package/releases/releases.yaml
CHANGED
|
@@ -40,8 +40,28 @@
|
|
|
40
40
|
# `npm install rcf-lite`.
|
|
41
41
|
|
|
42
42
|
feedVersion: 1
|
|
43
|
-
latest: "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
|
package/src/blueprint/index.js
CHANGED
|
@@ -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
|
+
}
|