@awebai/oats 0.33.0 → 0.34.1
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/bin/oats.mjs +122 -17
- package/docs/capabilities.md +17 -0
- package/docs/desktop-cli-api.md +184 -4
- package/docs/execution-targets.md +3 -3
- package/docs/implementation.md +36 -0
- package/docs/knowledge.md +4 -2
- package/docs/official-catalog.md +1 -1
- package/docs/release-notes/v0.34.0.md +63 -0
- package/docs/release-notes/v0.34.1.md +57 -0
- package/docs/souls-and-instances.md +15 -8
- package/docs/workspaces.md +12 -1
- package/lib/capability-contract.mjs +1 -1
- package/lib/capability-show.mjs +208 -0
- package/lib/core.mjs +3 -3
- package/lib/instance-resolution.mjs +26 -4
- package/lib/packages.mjs +1 -1
- package/lib/remote.mjs +259 -35
- package/lib/resolve.mjs +56 -14
- package/package-catalog.json +1 -1
- package/package.json +1 -1
package/lib/remote.mjs
CHANGED
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
* APPROACH (one approach, used for every remote kind — local bare repos and
|
|
5
5
|
* https/ssh remotes alike):
|
|
6
6
|
* 1. `observeRemote` resolves `at` with `git ls-remote --symref <url> …` —
|
|
7
|
-
* never a fetch when the caller already gave a full OID.
|
|
7
|
+
* never a fetch when the caller already gave a full OID. A HEAD observation
|
|
8
|
+
* speaks protocol v0 unless the operator pinned `protocol.version` (one round
|
|
9
|
+
* trip instead of v2's two; what is kept of the advertisement is bounded, and a
|
|
10
|
+
* remote over budget, or one that timed out under v0, is observed under v2:
|
|
11
|
+
* observeLive).
|
|
8
12
|
* 2. Every read (`readRemoteFile`, `listRemoteTree`, `fetchRemoteTree`) needs the
|
|
9
13
|
* commit locally. `ensureCommit` does a shallow, partial
|
|
10
14
|
* `git fetch --depth 1 --no-tags --filter=blob:limit=64k origin <oid>` into a
|
|
@@ -117,7 +121,15 @@
|
|
|
117
121
|
* unref'd while idle and ended by session.close();
|
|
118
122
|
* - `maxAge` (seconds): > 0 lets observeRemote reuse a recorded head observation
|
|
119
123
|
* (the observation store below) no older than that;
|
|
120
|
-
* - the head observations used, for the `observation` output block
|
|
124
|
+
* - the head observations used, for the `observation` output block;
|
|
125
|
+
* - `deadline` (DEADLINE; the deployment reads `oats status` and `oats workspace status` only, READ_REMOTE_BUDGET_MS
|
|
126
|
+
* after their session starts): every remote step gets what is left of it, not its own default — each git call's
|
|
127
|
+
* timeout (ls-remote, fetch, ls-tree, the batch readers' answers, every cache plumbing call, the git version
|
|
128
|
+
* probe), the cache write lock's wait, the half-initialised cache's wait and the lock-race backoff. A step the deadline ends is a
|
|
129
|
+
* `timeout` (git's group killed as any timeout kills it; a step not started yet starts no git), so a member
|
|
130
|
+
* not read by then degrades as any unreadable member does (a peel or version the deadline ended is a timeout,
|
|
131
|
+
* never a missing commit or an older git). A wait the deadline cuts never changes what it judges: past it no
|
|
132
|
+
* lock is taken or reclaimed, and a cache directory is not taken for a crash's leftover.
|
|
121
133
|
*
|
|
122
134
|
* PARSED CACHE (`memoAtCommit`, session only): a value derived only from the bytes at
|
|
123
135
|
* (repo key, commit) by a given kernel never changes, so it is kept on disk under
|
|
@@ -157,6 +169,13 @@ export const GIT_TIMEOUT_MS = 30_000;
|
|
|
157
169
|
/** The fetch of a commit into the cache: the first one transfers the commit's whole tree, which for a
|
|
158
170
|
* large workspace host takes far longer than GIT_TIMEOUT_MS (awebai/oats#362). */
|
|
159
171
|
export const GIT_FETCH_TIMEOUT_MS = 600_000;
|
|
172
|
+
/** The remote budget of a deployment read (`oats status`, `oats workspace status`): its session's `deadline` is
|
|
173
|
+
* this long after the session starts, so the command answers inside a caller's own limit (the Desktop's 30 s). */
|
|
174
|
+
export const READ_REMOTE_BUDGET_MS = 12_000;
|
|
175
|
+
/** What OATS keeps of a v0 ref advertisement (git's stdout, `maxBuffer`). It bounds memory, not the wire: git
|
|
176
|
+
* reads the whole advertisement before printing it, so one over budget is transferred once, then git is killed
|
|
177
|
+
* and the remote observed under v2, its server-side filter, from then on (observeLive). */
|
|
178
|
+
export const V0_ADVERTISEMENT_BUDGET = 4 * 1024 * 1024;
|
|
160
179
|
/** A session's tree index: the output budget of one `ls-tree -r -t -l -z <commit>`. */
|
|
161
180
|
export const TREE_INDEX_BUDGET = 64 * 1024 * 1024;
|
|
162
181
|
/** `--max-age` bounds (seconds). */
|
|
@@ -376,6 +395,21 @@ function abortError(signal) {
|
|
|
376
395
|
return Object.assign(new Error("The operation was aborted", { cause: signal?.reason }), { name: "AbortError", code: "ABORT_ERR", overflowed: false, timedOut: false });
|
|
377
396
|
}
|
|
378
397
|
|
|
398
|
+
/** A git call the session's deadline left no time for: shaped as runGit's own timeout (classified "timeout"). */
|
|
399
|
+
function deadlineError(args) {
|
|
400
|
+
return Object.assign(new Error(`Command failed: git ${args.join(" ")} (the read budget ended before it ran)`),
|
|
401
|
+
{ code: null, killed: false, signal: null, timedOut: true, overflowed: false, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** `exec(args, opts)` under the session's deadline: its timeout (opts.timeout, else GIT_TIMEOUT_MS) cut to what is
|
|
405
|
+
* left, and no git started once nothing is. Without a deadline, exactly `exec(args, opts)`. */
|
|
406
|
+
function sessionExec(exec, session, args, opts = {}) {
|
|
407
|
+
if (session?.deadline == null) return exec(args, opts);
|
|
408
|
+
const timeout = session.remaining(opts.timeout ?? GIT_TIMEOUT_MS);
|
|
409
|
+
if (timeout <= 0) return Promise.reject(deadlineError(args));
|
|
410
|
+
return exec(args, { ...opts, timeout });
|
|
411
|
+
}
|
|
412
|
+
|
|
379
413
|
function stderrText(error) {
|
|
380
414
|
const s = error?.stderr;
|
|
381
415
|
return Buffer.isBuffer(s) ? s.toString("utf8") : typeof s === "string" ? s : String(error?.message ?? "");
|
|
@@ -475,15 +509,17 @@ const isLockRace = (error) => /\.lock': File exists|Unable to create .*\.lock|an
|
|
|
475
509
|
* git that was killed: the wait covers the first, short writes; a fetch's lock is judged by its age. */
|
|
476
510
|
const LOCK_WAIT_MS = 3_000;
|
|
477
511
|
/** Run `fn` (one git call that writes the cache repo), again after a lost on-disk `.lock` race, until
|
|
478
|
-
* LOCK_WAIT_MS has passed (backoff up to 500 ms, jittered); a timeout or any other failure is never retried.
|
|
479
|
-
|
|
512
|
+
* LOCK_WAIT_MS has passed (backoff up to 500 ms, jittered); a timeout or any other failure is never retried.
|
|
513
|
+
* A session's deadline cuts the backoff: the next call then ends as its timeout (sessionExec). */
|
|
514
|
+
async function retryLockRace(fn, session = null) {
|
|
480
515
|
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
481
516
|
for (let attempt = 0; ; attempt++) {
|
|
482
517
|
try { return await fn(); }
|
|
483
518
|
catch (error) {
|
|
484
519
|
if (!isLockRace(error) || error.timedOut) throw error;
|
|
485
520
|
if (Date.now() >= deadline) throw error;
|
|
486
|
-
|
|
521
|
+
const backoff = Math.min(500, 50 * 2 ** attempt) * (0.5 + Math.random());
|
|
522
|
+
await sleep(session ? session.remaining(backoff) : backoff);
|
|
487
523
|
}
|
|
488
524
|
}
|
|
489
525
|
}
|
|
@@ -541,8 +577,18 @@ async function withCacheWriteLock(ref, dir, stage, session, fn) {
|
|
|
541
577
|
try { mkdirSync(dirname(lock), { recursive: true, mode: 0o700 }); } catch (error) { throw refuse(`cannot create ${dirname(lock)} (${error.code ?? error.message})`); }
|
|
542
578
|
const deadline = Date.now() + (session?.cacheWriteWaitMs ?? CACHE_WRITE_WAIT_MS);
|
|
543
579
|
let held = null, waited = false;
|
|
580
|
+
// The session's deadline ends the wait before anything is taken or reclaimed: the read is a timeout, and the
|
|
581
|
+
// lock (live, stale or unreadable) stays exactly as it is.
|
|
582
|
+
const budgetEnded = () => {
|
|
583
|
+
let now = held;
|
|
584
|
+
try { now = readCacheWriteLock(lock); } catch { /* as last seen */ }
|
|
585
|
+
const holder = now?.owner ? `oats process ${now.owner.pid}` : "another process";
|
|
586
|
+
return fail("E_REMOTE_UNREADABLE", `cannot write the cache of ${url} at ${dir} (timeout, ${stage}): the read budget ended ${now ? `while ${holder} held its write lock ${lock}` : `before it took its write lock ${lock}`}`,
|
|
587
|
+
{ url: ref.url, key: ref.key, reason: "timeout", stage, cacheDir: dir, lock, ...(now?.owner ? { holderPid: now.owner.pid } : {}) });
|
|
588
|
+
};
|
|
544
589
|
for (let attempt = 0; ; attempt++) {
|
|
545
590
|
if (signal?.aborted) throw unreadable(ref, abortError(signal), { cacheDir: dir, stage });
|
|
591
|
+
if (session?.expired()) throw budgetEnded();
|
|
546
592
|
const tmp = `${lock}.${owner.pid}.${owner.token}`;
|
|
547
593
|
try {
|
|
548
594
|
writeFileSync(tmp, JSON.stringify(owner) + "\n", { mode: 0o600 });
|
|
@@ -550,7 +596,7 @@ async function withCacheWriteLock(ref, dir, stage, session, fn) {
|
|
|
550
596
|
} catch (error) { throw refuse(`cannot take its write lock ${lock} (${error.code ?? error.message})`); }
|
|
551
597
|
finally { try { unlinkSync(tmp); } catch { /* never written */ } }
|
|
552
598
|
try { held = readCacheWriteLock(lock); } catch (error) { throw refuse(`cannot read its write lock ${lock} (${error.code ?? error.message}); remove it if no oats process is running`); }
|
|
553
|
-
if (held && isStaleLock(held)) {
|
|
599
|
+
if (held && isStaleLock(held) && !session?.expired()) {
|
|
554
600
|
const abandoned = reclaimCacheWriteLock(lock, held, owner);
|
|
555
601
|
if (abandoned) {
|
|
556
602
|
throw refuse(`${abandoned.guard} was left by ${abandoned.pid ? `oats process ${abandoned.pid}, which died` : "an oats process that died"} while reclaiming the write lock ${lock}; it is safe to remove once no oats process is running`,
|
|
@@ -561,8 +607,10 @@ async function withCacheWriteLock(ref, dir, stage, session, fn) {
|
|
|
561
607
|
throw refuse(held?.owner ? `oats process ${held.owner.pid} has been writing it since ${held.owner.startedAt} (lock ${lock}); try again once it finishes`
|
|
562
608
|
: `its write lock ${lock} is held; it is safe to remove once no oats process is running`, held?.owner ? { holderPid: held.owner.pid } : {});
|
|
563
609
|
}
|
|
610
|
+
if (session?.expired()) throw budgetEnded();
|
|
564
611
|
waited = true;
|
|
565
|
-
|
|
612
|
+
const backoff = Math.min(250, 25 * 2 ** attempt) * (0.5 + Math.random());
|
|
613
|
+
await sleep(session ? Math.max(1, session.remaining(backoff)) : backoff);
|
|
566
614
|
}
|
|
567
615
|
try { return await fn({ waited }); }
|
|
568
616
|
finally {
|
|
@@ -627,7 +675,7 @@ function reclaimGitLock(repo, lockFile) {
|
|
|
627
675
|
* the call made once more. Anything else is thrown as git raised it. */
|
|
628
676
|
async function cacheGit(repo, args, opts = {}) {
|
|
629
677
|
const run = () => repo.local(args, opts);
|
|
630
|
-
try { return await retryLockRace(run); }
|
|
678
|
+
try { return await retryLockRace(run, repo.session); }
|
|
631
679
|
catch (error) {
|
|
632
680
|
const lockFile = isLockRace(error) ? lockFileOf(error, repo.dir) : null;
|
|
633
681
|
if (!lockFile || !reclaimGitLock(repo, lockFile)) throw error;
|
|
@@ -646,15 +694,16 @@ function redactUrl(url) {
|
|
|
646
694
|
}
|
|
647
695
|
|
|
648
696
|
function repoHandle(dir, exec, session) {
|
|
649
|
-
const local = (args, opts = {}) => exec
|
|
697
|
+
const local = (args, opts = {}) => sessionExec(exec, session, ["-C", dir, "-c", "gc.auto=0", ...args], { cwd: dir, ...(session ? { signal: session.signal } : {}), ...opts });
|
|
650
698
|
return { dir, exec, local, session };
|
|
651
699
|
}
|
|
652
700
|
|
|
653
701
|
async function cacheRepo(ref, options) {
|
|
654
702
|
const exec = options.exec ?? runGit;
|
|
655
703
|
const dir = cacheDirOf(cacheRootOf(options), ref);
|
|
656
|
-
|
|
657
|
-
|
|
704
|
+
const session = sessionOf(options);
|
|
705
|
+
if (!existsSync(join(dir, "HEAD"))) await initCacheRepo(ref, dir, exec, session);
|
|
706
|
+
return repoHandle(dir, exec, session);
|
|
658
707
|
}
|
|
659
708
|
|
|
660
709
|
/** How long a cache directory without HEAD is waited for (an older kernel initialising it in place) before it
|
|
@@ -669,12 +718,12 @@ const HALF_INIT_WAIT_MS = 2_000;
|
|
|
669
718
|
* moved aside and replaced; the cache is disposable. Any other failure is E_REMOTE_UNREADABLE
|
|
670
719
|
* { reason: "cache", stage: "init", cacheDir }.
|
|
671
720
|
*/
|
|
672
|
-
async function initCacheRepo(ref, dir, exec) {
|
|
721
|
+
async function initCacheRepo(ref, dir, exec, session = null) {
|
|
673
722
|
const failInit = (error) => cacheFailure(ref, error, { cacheDir: dir, stage: "init" });
|
|
674
723
|
const tmp = `${dir}.init-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
675
724
|
try {
|
|
676
725
|
try { mkdirSync(tmp, { recursive: true, mode: 0o700 }); } catch (error) { throw failInit(error); }
|
|
677
|
-
try { await exec
|
|
726
|
+
try { await sessionExec(exec, session, ["init", "-q", "--bare", tmp]); } catch (error) { throw failInit(error); }
|
|
678
727
|
try { writeFileSync(join(tmp, "oats-remote.json"), JSON.stringify({ key: ref.key, url: redactUrl(ref.url) }, null, 2) + "\n"); } catch {}
|
|
679
728
|
const deadline = Date.now() + HALF_INIT_WAIT_MS;
|
|
680
729
|
for (;;) {
|
|
@@ -683,7 +732,13 @@ async function initCacheRepo(ref, dir, exec) {
|
|
|
683
732
|
if (error?.code !== "ENOTEMPTY" && error?.code !== "EEXIST") throw failInit(error);
|
|
684
733
|
}
|
|
685
734
|
if (existsSync(join(dir, "HEAD"))) return; // another process created it first
|
|
686
|
-
if (Date.now() < deadline) {
|
|
735
|
+
if (Date.now() < deadline) {
|
|
736
|
+
// The session's deadline never shortens the judgment: a directory waited for less than the whole wait is
|
|
737
|
+
// never taken for a leftover. The read ends as a timeout instead.
|
|
738
|
+
if (session?.expired()) throw failInit(deadlineError(["init", "-q", "--bare", dir]));
|
|
739
|
+
await sleep(Math.max(1, Math.min(50, session ? session.remaining(50) : 50)));
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
687
742
|
// No HEAD after the wait: a leftover. Move it aside (atomic; a process racing to do the same loses
|
|
688
743
|
// with ENOENT, which is fine) and take its place.
|
|
689
744
|
const aside = `${dir}.stale-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
@@ -705,8 +760,9 @@ function pinRef(oid) { return `refs/oats/commits/${oid}`; }
|
|
|
705
760
|
|
|
706
761
|
class ReadSession {
|
|
707
762
|
constructor({ maxAge = 0, now = Date.now, fingerprint = null, treeIndexBudget = TREE_INDEX_BUDGET, parsedLimits = null, batchTimeoutMs = GIT_TIMEOUT_MS,
|
|
708
|
-
cacheWriteWaitMs = CACHE_WRITE_WAIT_MS, fetchTimeoutMs = GIT_FETCH_TIMEOUT_MS } = {}) {
|
|
763
|
+
cacheWriteWaitMs = CACHE_WRITE_WAIT_MS, fetchTimeoutMs = GIT_FETCH_TIMEOUT_MS, deadline = null, v0AdvertisementBudget = V0_ADVERTISEMENT_BUDGET } = {}) {
|
|
709
764
|
if (!Number.isInteger(maxAge) || maxAge < 0 || maxAge > MAX_AGE_LIMIT) throw new TypeError(`maxAge must be an integer from 0 to ${MAX_AGE_LIMIT}`);
|
|
765
|
+
if (deadline !== null && !Number.isFinite(deadline)) throw new TypeError("deadline must be null or a time in Date.now() milliseconds");
|
|
710
766
|
this.maxAge = maxAge;
|
|
711
767
|
this.now = now;
|
|
712
768
|
this.startedAt = new Date(now()).toISOString();
|
|
@@ -715,6 +771,9 @@ class ReadSession {
|
|
|
715
771
|
this.batchTimeoutMs = batchTimeoutMs; // tests shorten it; a batch answer is otherwise waited for as long as any git call
|
|
716
772
|
this.cacheWriteWaitMs = cacheWriteWaitMs; // tests shorten it: how long a write waits for another live writer of its cache
|
|
717
773
|
this.fetchTimeoutMs = fetchTimeoutMs; // tests shorten it: a fetch's own timeout
|
|
774
|
+
this.deadline = deadline; // null, or when every remote step of the command must be over (remaining())
|
|
775
|
+
this.v0AdvertisementBudget = v0AdvertisementBudget; // tests lower it: the largest v0 ref advertisement read
|
|
776
|
+
this.protocolPins = new WeakMap(); // exec → Promise<whether protocol.version is pinned> (observeLive)
|
|
718
777
|
this.parsedLimits = parsedLimits; // tests inject small prune bounds
|
|
719
778
|
this.observations = new Map(); // memo key → Promise<head observation>
|
|
720
779
|
this.used = new Map(); // memo key → { observedAt, reused }: the heads this command used
|
|
@@ -793,12 +852,19 @@ class ReadSession {
|
|
|
793
852
|
this.retiring.clear();
|
|
794
853
|
reapOnExit(); // no timer runs on the exit path: SIGTERM, a bounded synchronous grace, then SIGKILL
|
|
795
854
|
}
|
|
855
|
+
/** What a wait or a git call of `ms` may take: `ms`, cut to what is left before the deadline (never below 0).
|
|
856
|
+
* Without a deadline, `ms` itself. */
|
|
857
|
+
remaining(ms) { return this.deadline === null ? ms : Math.max(0, Math.min(ms, this.deadline - Date.now())); }
|
|
858
|
+
/** Whether the deadline has passed: every remote step not over by then ends as a `timeout`. */
|
|
859
|
+
expired() { return this.remaining(Infinity) === 0; }
|
|
796
860
|
/** A session rides remoteOptions, which callers may serialise (memo keys): never its innards. */
|
|
797
861
|
toJSON() { return "[oats read session]"; }
|
|
798
862
|
}
|
|
799
863
|
|
|
800
|
-
/** One command's read session (see the module header). `maxAge` seconds (0 = observe live).
|
|
801
|
-
*
|
|
864
|
+
/** One command's read session (see the module header). `maxAge` seconds (0 = observe live). `deadline` (Date.now()
|
|
865
|
+
* milliseconds, or null): every remote step ends by then (the module header's DEADLINE). Test seams: `now`,
|
|
866
|
+
* `fingerprint`, `treeIndexBudget`, `parsedLimits`, `batchTimeoutMs`, `cacheWriteWaitMs`, `fetchTimeoutMs`,
|
|
867
|
+
* `v0AdvertisementBudget`. */
|
|
802
868
|
export function createReadSession(options = {}) { return new ReadSession(options); }
|
|
803
869
|
/** An observation the command no longer wants (its session closed, or its prefetch abandoned): never adopted
|
|
804
870
|
* by a caller that is still reading, so its shape only has to be a typed remote failure. */
|
|
@@ -1106,7 +1172,7 @@ async function ensureCommit(ref, oid, options) {
|
|
|
1106
1172
|
let peeled = false; // the object store was asked already, and said no
|
|
1107
1173
|
const usable = async (repo) => {
|
|
1108
1174
|
if (!existsSync(join(dir, "HEAD"))) return null;
|
|
1109
|
-
if (!keepsPartialCache(await
|
|
1175
|
+
if (!keepsPartialCache(await readVersion(repo, ref)) && await fetchMode(repo) === "partial") return null;
|
|
1110
1176
|
peeled = true;
|
|
1111
1177
|
return peelCommit(repo, oid);
|
|
1112
1178
|
};
|
|
@@ -1116,7 +1182,7 @@ async function ensureCommit(ref, oid, options) {
|
|
|
1116
1182
|
// waited for it finds what the holder fetched.
|
|
1117
1183
|
return withCacheWriteLock(ref, dir, "fetch", session, async ({ waited }) => {
|
|
1118
1184
|
let repo = await cacheRepo(ref, options);
|
|
1119
|
-
const version = await
|
|
1185
|
+
const version = await readVersion(repo, ref);
|
|
1120
1186
|
// A partial cache is only safe where git cannot fetch a missing blob on its own: an older git rebuilds it whole.
|
|
1121
1187
|
if (!keepsPartialCache(version) && await fetchMode(repo) === "partial") repo = await rebuildWhole(repo, ref, options, version);
|
|
1122
1188
|
const cached = peeled && !waited ? null : await peelCommit(repo, oid);
|
|
@@ -1129,6 +1195,8 @@ async function ensureCommit(ref, oid, options) {
|
|
|
1129
1195
|
if (mode === "partial" && /filtering not recognized by server/i.test(stderr.toString("utf8"))) await recordFullFetches(repo, ref);
|
|
1130
1196
|
const commit = await peelCommit(repo, oid);
|
|
1131
1197
|
if (!commit) {
|
|
1198
|
+
// A peel the session's deadline ended is the read's timeout, never a missing commit.
|
|
1199
|
+
if (session?.expired()) throw unreadable(ref, deadlineError(["rev-parse", `${oid}^{commit}`]), { commit: oid, cacheDir: repo.dir, stage: "fetch" });
|
|
1132
1200
|
const type = await objectType(repo, oid);
|
|
1133
1201
|
throw fail("E_REMOTE_UNREADABLE", `${oid} in ${ref.url} is ${type ? `a ${type}` : "missing"}, not a commit`, { url: ref.url, key: ref.key, reason: "not-found", commit: oid, type });
|
|
1134
1202
|
}
|
|
@@ -1186,19 +1254,41 @@ async function recordFullFetches(repo, ref, notice = `${redactUrl(ref.url)} does
|
|
|
1186
1254
|
const olderGitNotice = (version, ref) =>
|
|
1187
1255
|
`git ${version?.text ?? "(unknown version)"} cannot keep a partial cache (it needs ${PARTIAL_FETCH_GIT.join(".")}); OATS fetches whole trees from ${redactUrl(ref.url)}`;
|
|
1188
1256
|
|
|
1189
|
-
/** `git --version` of the git `exec` runs, asked once per exec: → { text: "2.54.0", major, minor } | null.
|
|
1257
|
+
/** `git --version` of the git `exec` runs, asked once per exec: → { text: "2.54.0", major, minor } | null.
|
|
1258
|
+
* `opts` (a timeout, a signal) go to the call that asks. A call that timed out says nothing about this git:
|
|
1259
|
+
* it answers null to its waiters and is asked again next time. */
|
|
1190
1260
|
const gitVersions = new WeakMap();
|
|
1191
|
-
export function gitVersion(exec = runGit) {
|
|
1261
|
+
export function gitVersion(exec = runGit, opts = undefined) {
|
|
1192
1262
|
let version = gitVersions.get(exec);
|
|
1193
1263
|
if (!version) {
|
|
1194
|
-
version = Promise.resolve().then(() => exec(["--version"])).then((out) => {
|
|
1264
|
+
version = Promise.resolve().then(() => exec(["--version"], ...(opts ? [opts] : []))).then((out) => {
|
|
1195
1265
|
const m = /git version ((\d+)\.(\d+)[^\s]*)/.exec(out.stdout.toString("utf8"));
|
|
1196
1266
|
return m ? { text: m[1], major: Number(m[2]), minor: Number(m[3]) } : null;
|
|
1197
|
-
}, () =>
|
|
1267
|
+
}, (error) => {
|
|
1268
|
+
if (error?.timedOut && gitVersions.get(exec) === version) gitVersions.delete(exec);
|
|
1269
|
+
return null;
|
|
1270
|
+
});
|
|
1198
1271
|
gitVersions.set(exec, version);
|
|
1199
1272
|
}
|
|
1200
1273
|
return version;
|
|
1201
1274
|
}
|
|
1275
|
+
/** gitVersion for a read of `ref` in `repo`'s session. Under a deadline the probe gets what is left of it, and
|
|
1276
|
+
* is waited for no longer (another caller may own the probe): a version the deadline left unknown is the
|
|
1277
|
+
* read's timeout, never taken for an older git (which would rebuild a partial cache). */
|
|
1278
|
+
async function readVersion(repo, ref) {
|
|
1279
|
+
const session = repo.session;
|
|
1280
|
+
if (session?.deadline == null) return gitVersion(repo.exec);
|
|
1281
|
+
const ended = () => unreadable(ref, deadlineError(["--version"]), { cacheDir: repo.dir, stage: "fetch" });
|
|
1282
|
+
const left = session.remaining(GIT_TIMEOUT_MS);
|
|
1283
|
+
if (left <= 0) throw ended();
|
|
1284
|
+
let timer;
|
|
1285
|
+
const late = new Promise((resolvePromise) => { timer = setTimeout(() => resolvePromise(ended), left); });
|
|
1286
|
+
try {
|
|
1287
|
+
const version = await Promise.race([gitVersion(repo.exec, { timeout: left, signal: session.signal }), late]);
|
|
1288
|
+
if (version === ended || (version === null && session.expired())) throw ended();
|
|
1289
|
+
return version;
|
|
1290
|
+
} finally { clearTimeout(timer); }
|
|
1291
|
+
}
|
|
1202
1292
|
/** Whether this git keeps a partial cache honest (GIT_NO_LAZY_FETCH): an unknown version does not. */
|
|
1203
1293
|
export function keepsPartialCache(version) {
|
|
1204
1294
|
const [major, minor] = PARTIAL_FETCH_GIT;
|
|
@@ -1311,7 +1401,7 @@ function parseLsRemote(stdout) {
|
|
|
1311
1401
|
}
|
|
1312
1402
|
|
|
1313
1403
|
function resolveAt(parsed, at) {
|
|
1314
|
-
if (at
|
|
1404
|
+
if (isHeadAt(at)) {
|
|
1315
1405
|
const oid = parsed.oids.get("HEAD");
|
|
1316
1406
|
return oid ? { commit: oid, ref: parsed.symrefs.get("HEAD") ?? null } : null;
|
|
1317
1407
|
}
|
|
@@ -1355,7 +1445,7 @@ export async function observeRemote(refText, { at, ...options } = {}) {
|
|
|
1355
1445
|
|
|
1356
1446
|
/** The `ls-remote` argv of a head observation (`at`: HEAD, a tag or a branch; never a full OID). */
|
|
1357
1447
|
function lsRemoteArgs(ref, at) {
|
|
1358
|
-
const wantHead = at
|
|
1448
|
+
const wantHead = isHeadAt(at);
|
|
1359
1449
|
if (!wantHead && AT_BAD_RE.test(at)) throw fail("E_REPO_REF", `at must be a full OID or a plain tag/branch name, got ${JSON.stringify(at)}`, { at });
|
|
1360
1450
|
const args = ["ls-remote", "--symref", ref.url];
|
|
1361
1451
|
if (wantHead) args.push("HEAD");
|
|
@@ -1439,19 +1529,115 @@ async function observeInSession(ref, args, at, options, session, prefetch = null
|
|
|
1439
1529
|
} finally { session.observeDone(); }
|
|
1440
1530
|
}
|
|
1441
1531
|
|
|
1532
|
+
/** Whether `at` asks for the remote's default branch (a HEAD observation). */
|
|
1533
|
+
const isHeadAt = (at) => at === undefined || at === null || at === "" || at === "HEAD";
|
|
1534
|
+
|
|
1535
|
+
/** Whether the operator pinned `protocol.version` (env, global, system or the working directory's repo config:
|
|
1536
|
+
* `git config --get`, in the observation's own environment): asked once per command (its session) and exec, or
|
|
1537
|
+
* once per exec without a session. Unset (exit 1) → false; any value, or a read that fails otherwise (an abort
|
|
1538
|
+
* included) → true: today's argv, never an error. */
|
|
1539
|
+
const protocolPins = new WeakMap();
|
|
1540
|
+
function protocolPinned(exec, session) {
|
|
1541
|
+
const memo = session?.protocolPins ?? protocolPins;
|
|
1542
|
+
let pinned = memo.get(exec);
|
|
1543
|
+
if (!pinned) {
|
|
1544
|
+
pinned = Promise.resolve().then(() => sessionExec(exec, session, ["config", "--get", "protocol.version"], { timeout: GIT_TIMEOUT_MS, ...(session ? { signal: session.signal } : {}) }))
|
|
1545
|
+
.then(() => true, (error) => error?.code !== 1);
|
|
1546
|
+
memo.set(exec, pinned);
|
|
1547
|
+
}
|
|
1548
|
+
return pinned;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/** The records that a remote is observed under protocol v2 only: `<cacheRoot>/.ls-remote/<sha256(key)>.<reason>.json`,
|
|
1552
|
+
* beside the cache repos and their `.locks/` (written when no cache repo exists yet, and gone with a wiped cache
|
|
1553
|
+
* root), each { protocol: "v2", reason, recordedAt }. `overflow` (its v0 advertisement is over budget) holds for
|
|
1554
|
+
* good; `timeout` (a v0 observation timed out, which load alone can cause) for LS_REMOTE_TIMEOUT_RECORD_MS. One
|
|
1555
|
+
* file per reason, so a timeout recorded by a command already in flight never replaces a permanent overflow.
|
|
1556
|
+
* Written atomically (temp + rename) with no lock: concurrent writers of one file write the same fact. A record
|
|
1557
|
+
* that cannot be read, is corrupt or has expired is no record: v0 is tried, never an error. */
|
|
1558
|
+
const LS_REMOTE_TIMEOUT_RECORD_MS = 7 * 24 * 3600 * 1000;
|
|
1559
|
+
const lsRemoteRecordFile = (root, ref, reason) => join(root, ".ls-remote", `${sha256(ref.key)}.${reason}.json`);
|
|
1560
|
+
function lsRemoteV2Recorded(root, ref, now) {
|
|
1561
|
+
const record = (reason) => {
|
|
1562
|
+
const rec = readStoreFile(lsRemoteRecordFile(root, ref, reason));
|
|
1563
|
+
return rec && typeof rec === "object" && rec.protocol === "v2" && rec.reason === reason ? rec : null;
|
|
1564
|
+
};
|
|
1565
|
+
if (record("overflow")) return true;
|
|
1566
|
+
const rec = record("timeout");
|
|
1567
|
+
const at = typeof rec?.recordedAt === "string" ? Date.parse(rec.recordedAt) : NaN;
|
|
1568
|
+
return Number.isFinite(at) && at <= now + 5000 && now - at < LS_REMOTE_TIMEOUT_RECORD_MS;
|
|
1569
|
+
}
|
|
1570
|
+
function recordLsRemoteV2(root, ref, reason, now) {
|
|
1571
|
+
writeAtomicQuiet(lsRemoteRecordFile(root, ref, reason), JSON.stringify({ protocol: "v2", reason, recordedAt: new Date(now).toISOString() }) + "\n");
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/** A v0 HEAD observation's failure that is final, exactly as under v2: the remote is slow, refuses us or has no
|
|
1575
|
+
* such repository, our cache failed, or the command gave the read up. Anything else is retried under v2. */
|
|
1576
|
+
const V0_FINAL_REASONS = new Set(["timeout", "auth", "not-found", "cache"]);
|
|
1577
|
+
|
|
1578
|
+
/**
|
|
1579
|
+
* `ls-remote` the remote and resolve `at`. A HEAD observation (`at` HEAD or unset) speaks protocol v0 when the
|
|
1580
|
+
* operator has not pinned `protocol.version` and the remote has no v2 record: one round trip, the whole ref
|
|
1581
|
+
* advertisement (`-c protocol.version=0 ls-remote --symref <url>`, no pattern), resolved to exactly what v2's
|
|
1582
|
+
* filtered answer gives, HEAD's symref included (v0's symref capability).
|
|
1583
|
+
* - V0_ADVERTISEMENT_BUDGET bounds what is kept (`maxBuffer`), not what crosses the wire: git reads the whole
|
|
1584
|
+
* advertisement before it prints a ref. Over budget, git is killed, the remote is observed again under v2,
|
|
1585
|
+
* recorded for good (`overflow`) and said once: an over-budget remote costs its advertisement once.
|
|
1586
|
+
* - A v0 timeout is today's error (no retry, never a second timeout), recorded for a week (`timeout`) and said.
|
|
1587
|
+
* - Any other failure in V0_FINAL_REASONS (or an abort) is today's error; any other is retried once under v2
|
|
1588
|
+
* and said once if the retry succeeds.
|
|
1589
|
+
* `args` (lsRemoteArgs) stays the observation's identity everywhere (records, memo keys) and is the v2 argv; tags
|
|
1590
|
+
* and branches always use it.
|
|
1591
|
+
*/
|
|
1442
1592
|
async function observeLive(ref, args, at, options) {
|
|
1443
1593
|
const exec = options.exec ?? runGit;
|
|
1444
|
-
const
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1594
|
+
const session = sessionOf(options);
|
|
1595
|
+
const signal = session?.signal;
|
|
1596
|
+
// Every git call takes what is left of the session's deadline, if it has one (sessionExec).
|
|
1597
|
+
const run = (argv, extra = {}) => sessionExec(exec, session, argv, { timeout: GIT_TIMEOUT_MS, ...(signal ? { signal } : {}), ...extra });
|
|
1598
|
+
const root = cacheRootOf(options);
|
|
1599
|
+
const now = () => (session ? session.now() : Date.now());
|
|
1600
|
+
const url = redactUrl(ref.url);
|
|
1601
|
+
const say = (notice) => { if (session && !session.notices.includes(notice)) session.notices.push(notice); };
|
|
1602
|
+
let out = null, retried = null;
|
|
1603
|
+
const v0 = isHeadAt(at) && !lsRemoteV2Recorded(root, ref, now()) && !(await protocolPinned(exec, session));
|
|
1604
|
+
// A command given up while its protocol was asked starts no ls-remote.
|
|
1605
|
+
if (signal?.aborted) throw unreadable(ref, abortError(signal), { at: at ?? null });
|
|
1606
|
+
if (v0) {
|
|
1607
|
+
const budget = session?.v0AdvertisementBudget ?? V0_ADVERTISEMENT_BUDGET;
|
|
1608
|
+
// A read whose timeout the command's deadline cuts (status, workspace status) may time out for that alone,
|
|
1609
|
+
// which says nothing about the remote: its timeout is not recorded.
|
|
1610
|
+
const cut = session ? session.remaining(GIT_TIMEOUT_MS) < GIT_TIMEOUT_MS : false;
|
|
1611
|
+
try { out = await run(["-c", "protocol.version=0", "ls-remote", "--symref", ref.url], { maxBuffer: budget }); }
|
|
1612
|
+
catch (error) {
|
|
1613
|
+
if (error?.overflowed === true || error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
|
|
1614
|
+
recordLsRemoteV2(root, ref, "overflow", now());
|
|
1615
|
+
say(`${url} sends a ref advertisement over ${formatBytes(budget)}; OATS observes it with protocol v2`);
|
|
1616
|
+
} else {
|
|
1617
|
+
const reason = classifyRemoteFailure(error);
|
|
1618
|
+
const aborted = error?.code === "ABORT_ERR" || signal?.aborted;
|
|
1619
|
+
if (!aborted && reason === "timeout" && !cut) {
|
|
1620
|
+
recordLsRemoteV2(root, ref, "timeout", now());
|
|
1621
|
+
say(`${url} timed out under protocol v0; OATS observes it with protocol v2 for 7 days`);
|
|
1622
|
+
}
|
|
1623
|
+
if (aborted || V0_FINAL_REASONS.has(reason)) throw unreadable(ref, error, { at: at ?? null });
|
|
1624
|
+
retried = reason;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
if (!out) {
|
|
1629
|
+
try { out = await run(args); }
|
|
1630
|
+
catch (error) { throw unreadable(ref, error, { at: at ?? null }); }
|
|
1631
|
+
}
|
|
1448
1632
|
const parsed = parseLsRemote(out.stdout);
|
|
1449
1633
|
const hit = resolveAt(parsed, at);
|
|
1450
1634
|
if (!hit) throw fail("E_REMOTE_UNREADABLE", `remote ${ref.url} has no ref matching ${at ?? "HEAD"}`, { url: ref.url, key: ref.key, reason: "not-found", at: at ?? null });
|
|
1451
1635
|
if (!OID_RE.test(hit.commit)) throw fail("E_REMOTE_UNREADABLE", `remote ${ref.url} returned a non-OID for ${at ?? "HEAD"}`, { url: ref.url, key: ref.key, reason: "not-found", at: at ?? null });
|
|
1452
1636
|
const { commit } = await ensureCommit(ref, hit.commit, options);
|
|
1637
|
+
if (retried) say(`${url} failed under protocol v0 (${retried}); observed with protocol v2`);
|
|
1453
1638
|
return { key: ref.key, url: ref.url, commit, ref: hit.ref, observedAt: new Date().toISOString() };
|
|
1454
1639
|
}
|
|
1640
|
+
const formatBytes = (n) => (n % (1024 * 1024) === 0 ? `${n / (1024 * 1024)} MiB` : `${n} bytes`);
|
|
1455
1641
|
|
|
1456
1642
|
// ---------------------------------------------------------------------------
|
|
1457
1643
|
// tree reading
|
|
@@ -1578,11 +1764,11 @@ function killChild(child) {
|
|
|
1578
1764
|
* like repo.local (same `-C <dir> -c gc.auto=0`, cwd, helper-free gitEnv()); unref'd while idle, so it
|
|
1579
1765
|
* never keeps the process alive. A header over `budget` rejects that request with `{ oversize }` and its
|
|
1580
1766
|
* body is skipped, never buffered; a non-blob answers as `cat-file blob` would ("bad file"). `missing`,
|
|
1581
|
-
* the child dying (or never starting: ENOENT), or no answer within GIT_TIMEOUT_MS
|
|
1582
|
-
* request with a git-shaped error (classifyRemoteFailure reads it
|
|
1583
|
-
* ends the child; the next read starts a new one.
|
|
1767
|
+
* the child dying (or never starting: ENOENT), or no answer within GIT_TIMEOUT_MS (cut to what is left of
|
|
1768
|
+
* `session`'s deadline) rejects EVERY pending request with a git-shaped error (classifyRemoteFailure reads it
|
|
1769
|
+
* as today: timeout → "timeout") and ends the child; the next read starts a new one.
|
|
1584
1770
|
*/
|
|
1585
|
-
function openBatch(dir, timeoutMs = GIT_TIMEOUT_MS) {
|
|
1771
|
+
function openBatch(dir, timeoutMs = GIT_TIMEOUT_MS, session = null) {
|
|
1586
1772
|
// Detached, as its own process group: killChild's group kill also ends anything git started.
|
|
1587
1773
|
const child = watchGroup(spawn("git", ["-C", dir, "-c", "gc.auto=0", "cat-file", "--batch"], { cwd: dir, env: gitEnv(), detached: true, stdio: ["pipe", "pipe", "pipe"] }));
|
|
1588
1774
|
liveChildren.add(child);
|
|
@@ -1596,7 +1782,8 @@ function openBatch(dir, timeoutMs = GIT_TIMEOUT_MS) {
|
|
|
1596
1782
|
const arm = () => {
|
|
1597
1783
|
clearTimeout(timer); timer = null;
|
|
1598
1784
|
if (!queue.length) { idle(); return; }
|
|
1599
|
-
|
|
1785
|
+
const ms = session ? session.remaining(timeoutMs) : timeoutMs;
|
|
1786
|
+
timer = setTimeout(() => die(Object.assign(new Error(`git cat-file --batch: no answer within ${ms} ms`), { killed: true, signal: "SIGKILL", timedOut: true, stderr: Buffer.from(stderr) })), ms);
|
|
1600
1787
|
};
|
|
1601
1788
|
const die = (error) => {
|
|
1602
1789
|
if (dead) return;
|
|
@@ -1686,7 +1873,8 @@ function catFileBatch(repo) {
|
|
|
1686
1873
|
if (!session || session.closed || repo.exec !== runGit) return null;
|
|
1687
1874
|
let reader = session.batches.get(repo.dir);
|
|
1688
1875
|
if (reader && !reader.dead) { session.batches.delete(repo.dir); session.batches.set(repo.dir, reader); return reader; }
|
|
1689
|
-
|
|
1876
|
+
if (session.expired()) return null; // a per-blob read, which ends as the deadline's timeout (sessionExec)
|
|
1877
|
+
reader = openBatch(repo.dir, session.batchTimeoutMs, session);
|
|
1690
1878
|
session.batches.set(repo.dir, reader);
|
|
1691
1879
|
if (session.batches.size > BATCH_LIMIT) {
|
|
1692
1880
|
for (const [dir, other] of session.batches) {
|
|
@@ -1795,6 +1983,42 @@ export async function listRemoteTree(refText, commitArg, dir, { depth = 2, ...op
|
|
|
1795
1983
|
return result;
|
|
1796
1984
|
}
|
|
1797
1985
|
|
|
1986
|
+
/**
|
|
1987
|
+
* → { files: [{ path, size }], total } for the regular files (blobs) under <dir> at <commit>, recursively,
|
|
1988
|
+
* relative to <dir>, sorted by path in codepoint order (feature capability-show): `total` of them in all, and
|
|
1989
|
+
* `files` the first `limit` (every one without a limit). Symlinks and gitlinks are omitted; a name that could
|
|
1990
|
+
* escape a checkout ANYWHERE below <dir> is refused (E_REMOTE_TREE_UNSAFE, as listRemoteTree), past the limit
|
|
1991
|
+
* too. One listing (the session's tree index when it has one); the sizes a partial cache lacks are learned with
|
|
1992
|
+
* ONE ensureBlobs call over the listed files only, so nothing past the limit is fetched. A missing dir, or one
|
|
1993
|
+
* that is not a directory → { files: [], total: 0 }. Memoized at the commit (memoAtCommit), per limit.
|
|
1994
|
+
*/
|
|
1995
|
+
export async function listRemoteFiles(refText, commitArg, dir, { limit, ...options } = {}) {
|
|
1996
|
+
const ref = parseRepoRef(refText, options);
|
|
1997
|
+
requireCommit(commitArg);
|
|
1998
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 0)) throw fail("E_REPO_REF", "limit must be a non-negative integer", { limit });
|
|
1999
|
+
const rel = normalizeTreePath(dir, { allowRoot: true });
|
|
2000
|
+
const none = { files: [], total: 0 };
|
|
2001
|
+
return memoAtCommit(ref, commitArg, `files\0${rel}\0${limit ?? "all"}`, async () => {
|
|
2002
|
+
const { repo, commit } = await ensureCommit(ref, commitArg, options);
|
|
2003
|
+
if (rel) {
|
|
2004
|
+
const entry = await entryAt(repo, commit, rel, ref);
|
|
2005
|
+
if (!entry || entry.type !== "tree") return none;
|
|
2006
|
+
}
|
|
2007
|
+
const index = await treeIndex(repo, commit, ref);
|
|
2008
|
+
const entries = index ? indexDescendants(index, rel) : await lsTree(repo, rel ? `${commit}:${rel}` : commit, { flags: ["-r"], ref, commit });
|
|
2009
|
+
if (!entries) return none;
|
|
2010
|
+
const blobs = [];
|
|
2011
|
+
for (const e of entries) {
|
|
2012
|
+
assertSafeEntryPath(e.path, ref, commit, rel ? `${rel}/${e.path}` : e.path);
|
|
2013
|
+
if (e.type === "blob" && e.mode !== "120000") blobs.push({ ...e });
|
|
2014
|
+
}
|
|
2015
|
+
blobs.sort(byPath);
|
|
2016
|
+
const listed = limit === undefined ? blobs : blobs.slice(0, limit);
|
|
2017
|
+
await ensureBlobs(repo, ref, commit, listed);
|
|
2018
|
+
return { files: listed.map((e) => ({ path: e.path, size: e.size })), total: blobs.length };
|
|
2019
|
+
}, options);
|
|
2020
|
+
}
|
|
2021
|
+
|
|
1798
2022
|
// ---------------------------------------------------------------------------
|
|
1799
2023
|
// digest
|
|
1800
2024
|
// ---------------------------------------------------------------------------
|
package/lib/resolve.mjs
CHANGED
|
@@ -474,12 +474,29 @@ export function packageRef(id, entry, catalog, remote) {
|
|
|
474
474
|
const SAFE_REL = (rel) => typeof rel === "string" && rel && !posix.isAbsolute(rel) && !/^[\\/]/.test(rel) && !/\0/.test(rel) && !rel.split(/[\\/]/).some((p) => p === "..");
|
|
475
475
|
|
|
476
476
|
/** Normalize a manifest-declared relative path ("./skills/x/" → "skills/x"); null when unsafe. */
|
|
477
|
-
function declaredPath(rel) {
|
|
477
|
+
export function declaredPath(rel) {
|
|
478
478
|
if (!SAFE_REL(rel)) return null;
|
|
479
479
|
const n = posix.normalize(rel).replace(/\/+$/, "");
|
|
480
480
|
return n === "." || n === "" || n.startsWith("../") ? null : n;
|
|
481
481
|
}
|
|
482
482
|
|
|
483
|
+
/** A relative path no listing of a checkout can hold: absolute, empty, a `.`/`..`/`.git` (any case) or empty
|
|
484
|
+
* component, a trailing slash, a backslash or a NUL. */
|
|
485
|
+
export function unsafeRelPath(path) {
|
|
486
|
+
if (typeof path !== "string" || path === "" || path.startsWith("/") || path.endsWith("/") || /[\\\0]/.test(path)) return true;
|
|
487
|
+
return path.split("/").some((c) => c === "" || c === "." || c === ".." || c.toLowerCase() === ".git");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** A manifest-declared path as the module install reads it (lib/materialize.mjs modulePath: a backslash is a
|
|
491
|
+
* separator, `./` and trailing slashes dropped), and only when the result is a safe POSIX path (unsafeRelPath);
|
|
492
|
+
* else null. What `oats capabilities show` reports for an inject or a skill. */
|
|
493
|
+
export function manifestFilePath(rel) {
|
|
494
|
+
const declared = declaredPath(rel);
|
|
495
|
+
if (!declared) return null;
|
|
496
|
+
const n = posix.normalize(declared.replace(/\\/g, "/")).replace(/^(?:\.\/)+/, "").replace(/\/+$/, "");
|
|
497
|
+
return unsafeRelPath(n) ? null : n;
|
|
498
|
+
}
|
|
499
|
+
|
|
483
500
|
/**
|
|
484
501
|
* Skills declared by a manifest: each `skills[]` entry is a directory under the capability that either
|
|
485
502
|
* IS a skill (holds SKILL.md) or holds skill directories (<entry>/<skill>/SKILL.md) — the same reading
|
|
@@ -525,14 +542,31 @@ async function enumerateSkills({ remote, ref, commit, dir, manifest, listing, mo
|
|
|
525
542
|
* would), commands and hooks. `skills` is null when its declared skills cannot be listed (a spawn of it
|
|
526
543
|
* would refuse; the listing does not). */
|
|
527
544
|
export async function capabilityProvides({ ref, commit, dir, manifest, remote: injected, remoteOptions }) {
|
|
528
|
-
const remote = remoteOf({ remote: injected, remoteOptions });
|
|
529
545
|
const keys = (o) => (isObject(o) ? Object.keys(o).sort(byCodepoint) : []);
|
|
530
|
-
|
|
546
|
+
const { skills } = await capabilitySkills({ ref, commit, dir, manifest, remote: injected, remoteOptions });
|
|
547
|
+
return { skills: skills && skills.map((s) => s.name), commands: keys(manifest.commands), hooks: keys(manifest.hooks) };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** A capability's skills as a spawn enumerates them (enumerateSkills), sorted by name in codepoint order:
|
|
551
|
+
* → { skills: [{ name, path }] (path relative to the capability directory), problem: null }, or
|
|
552
|
+
* { skills: null, problem } when the declared skills cannot be enumerated — `problem` the oats error a spawn
|
|
553
|
+
* would meet (`missingCode`: E_CAPABILITY_MISSING for a member, E_PACKAGE_MANIFEST for a package), or a skill
|
|
554
|
+
* path that is not safe (manifestFilePath). Paths are as the install reads them. The one reading of
|
|
555
|
+
* `manifest.skills` behind `oats capabilities` (capabilityProvides) and `oats capabilities show`. */
|
|
556
|
+
export async function capabilitySkills({ ref, commit, dir, manifest, remote: injected, remoteOptions, missingCode = "E_CAPABILITY_MISSING" }) {
|
|
557
|
+
const remote = remoteOf({ remote: injected, remoteOptions });
|
|
558
|
+
const missing = (raw, why, text) => fail(missingCode, `${manifest.capability} ${text}`, { capability: manifest.capability, skill: raw, why });
|
|
531
559
|
try {
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
560
|
+
const skills = [];
|
|
561
|
+
for (const { name, path } of await enumerateSkills({ remote, ref, commit, dir, manifest, moduleName: manifest.capability, missing })) {
|
|
562
|
+
// A skill path is reported as the install reads it, and only when it is a safe POSIX path (a `.git`
|
|
563
|
+
// directory the remote serves is one a spawn's fetch refuses).
|
|
564
|
+
const safe = manifestFilePath(path);
|
|
565
|
+
if (!safe) throw missing(path, "unsafe", `declares skill ${show(name)} at ${show(path)}, which is not a safe path inside the capability`);
|
|
566
|
+
skills.push({ name, path: safe });
|
|
567
|
+
}
|
|
568
|
+
return { skills: skills.sort((a, b) => byCodepoint(a.name, b.name)), problem: null };
|
|
569
|
+
} catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return { skills: null, problem: e }; throw e; }
|
|
536
570
|
}
|
|
537
571
|
|
|
538
572
|
/** The capability manifests of a locked package, read at its locked commit (feature desktop-facts). */
|
|
@@ -543,6 +577,20 @@ export async function lockedPackageCapabilities(id, entry, { catalog = null, rem
|
|
|
543
577
|
return { ref, capabilities };
|
|
544
578
|
}
|
|
545
579
|
|
|
580
|
+
/** The lock must describe the tree it names: the capability list a package's manifests declare at the locked
|
|
581
|
+
* commit (`capabilities`, readPackageManifests) must equal the lock entry's, and must hold `name`, else
|
|
582
|
+
* E_PACKAGE_INTEGRITY. → that capability { name, dir, manifest }. Spawn's check (resolveSoul), shared with
|
|
583
|
+
* `oats capabilities show`. */
|
|
584
|
+
export function lockedCapability(name, id, entry, capabilities, details = { capability: name, id, version: entry.version, commit: entry.commit, path: entry.path }) {
|
|
585
|
+
const listed = capabilities.map((c) => c.name).sort(), locked = [...entry.capabilities].sort(); // validateLock guarantees the array
|
|
586
|
+
if (listed.length !== locked.length || listed.some((c, i) => c !== locked[i])) {
|
|
587
|
+
throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides [${locked.join(", ")}], but ${entry.path}/oats-package.json at ${short(entry.commit)} declares [${listed.join(", ")}]`, { ...details, why: "capabilities", listed, locked });
|
|
588
|
+
}
|
|
589
|
+
const cap = capabilities.find((c) => c.name === name);
|
|
590
|
+
if (!cap) throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides it, but ${entry.path}/oats-package.json at ${short(entry.commit)} does not`, { ...details, listed: capabilities.map((c) => c.name) });
|
|
591
|
+
return cap;
|
|
592
|
+
}
|
|
593
|
+
|
|
546
594
|
/* ───────────────────────────── resolveSoul ────────────────────────────── */
|
|
547
595
|
|
|
548
596
|
/**
|
|
@@ -601,13 +649,7 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
|
|
|
601
649
|
const ref = packageRef(id, entry, catalog, remote);
|
|
602
650
|
const details = { capability: name, id, version: entry.version, commit: entry.commit, path: entry.path };
|
|
603
651
|
const { capabilities } = await readPackageManifests(remote, ref, entry.commit, entry.path, details);
|
|
604
|
-
|
|
605
|
-
const listed = capabilities.map((c) => c.name).sort(), locked = [...entry.capabilities].sort(); // validateLock guarantees the array
|
|
606
|
-
if (listed.length !== locked.length || listed.some((c, i) => c !== locked[i])) {
|
|
607
|
-
throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides [${locked.join(", ")}], but ${entry.path}/oats-package.json at ${short(entry.commit)} declares [${listed.join(", ")}]`, { ...details, why: "capabilities", listed, locked });
|
|
608
|
-
}
|
|
609
|
-
const cap = capabilities.find((c) => c.name === name);
|
|
610
|
-
if (!cap) throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides it, but ${entry.path}/oats-package.json at ${short(entry.commit)} does not`, { ...details, listed: capabilities.map((c) => c.name) });
|
|
652
|
+
const cap = lockedCapability(name, id, entry, capabilities, details);
|
|
611
653
|
const layer = layerOf(cap.manifest);
|
|
612
654
|
if (layer && emptied.has(layer) && via !== "soul") { turnedOff.push({ name, reason: "slot-none", slot: layer, overrides: fromOfVia(via) }); continue; }
|
|
613
655
|
module = {
|