feat(#1463): add capability outdated (per-source update check); drop phantom slash-command docs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,8 @@ const capValidator = require('./capability-validator.cjs') as ValidatorModule;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const semverMod = require('./semver-compare.cjs') as {
|
||||
semverSatisfies: (version: unknown, range: unknown) => boolean;
|
||||
compareSemverCore: (a: unknown, b: unknown) => -1 | 0 | 1;
|
||||
isStableTripletSemver: (v: unknown) => boolean;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -1089,6 +1091,357 @@ async function resolveCapabilitySource(spec: string, opts: ResolveOptions = {}):
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Latest-version peek (ADR-1244 D6 "Update available?" per-source matrix; #1463)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* #1463: timeouts for the LIGHT remote peek the `outdated` verb performs. These are deliberately the
|
||||
* SAME bounds the resolve path uses for the analogous heavy operations (CONTEXT.md "every git/npm
|
||||
* subprocess needs a timeout"): a hung registry/remote must DEGRADE the verb (status 'unknown'), never
|
||||
* hang it. The peek is a metadata-only read (`git ls-remote --tags`, `npm view … version`), NOT a
|
||||
* clone / pack / extract.
|
||||
*/
|
||||
const PEEK_GIT_TIMEOUT_MS = 30_000;
|
||||
const PEEK_NPM_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Status of a single per-source latest-version peek.
|
||||
* - ok a latest version was resolved (compare it to installed).
|
||||
* - pinned the recorded source is pinned to an immutable/explicit ref (git `#sha:`/`#tag:`/`#<ref>`)
|
||||
* or an EXACT npm version (`npm:<pkg>@1.2.3`). `update` re-resolves to the SAME ref/version,
|
||||
* so it can never be "outdated" — version (when known) is informational only.
|
||||
* - manual a bare tarball URL — one immutable artifact, no catalogue to query.
|
||||
* - unsupported the source kind has no implemented peek (registry).
|
||||
* - unknown the peek failed / timed out / returned unparseable output (DEGRADE, never thrown).
|
||||
*/
|
||||
type PeekStatus = 'ok' | 'pinned' | 'manual' | 'unsupported' | 'unknown';
|
||||
|
||||
/** Result of peekLatestVersion: a status discriminant + the resolved version when status==='ok'. */
|
||||
interface PeekResult {
|
||||
status: PeekStatus;
|
||||
/** The latest version string when status==='ok'; null otherwise. */
|
||||
version: string | null;
|
||||
/** Optional human-readable reason for a non-ok status (DEGRADE diagnostics, never thrown). */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1463: parse the output of `git ls-remote --tags <url>` and return the HIGHEST stable-triplet semver
|
||||
* tag, or null when no parseable semver tag exists. UNTRUSTED-DATA RULE: the remote's ref names are
|
||||
* treated purely as data — each line is `<sha>\t<ref>` (e.g. `<sha>\trefs/tags/v1.2.0`); we strip
|
||||
* `refs/tags/`, ignore the `^{}` peeled-annotation entries (they would otherwise double-count and the
|
||||
* `^{}` suffix is not a version), strip a leading `v`, and keep only STABLE x.y.z triplets
|
||||
* (isStableTripletSemver) so a `-rc`/junk tag never wins. The max is selected via compareSemverCore so
|
||||
* 1.10.0 correctly beats 1.2.0 (numeric, not lexical). Pure + deterministic → property-tested.
|
||||
*/
|
||||
function pickHighestSemverTag(lsRemoteOutput: string): string | null {
|
||||
if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') return null;
|
||||
let best: string | null = null;
|
||||
for (const rawLine of lsRemoteOutput.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (line === '') continue;
|
||||
// `<sha>\t<ref>` — take the ref (last whitespace-delimited token); a line without a tab/ref is junk.
|
||||
const tabIdx = line.search(/\s/);
|
||||
const ref = tabIdx === -1 ? line : line.slice(tabIdx + 1).trim();
|
||||
if (!ref.startsWith('refs/tags/')) continue;
|
||||
let tag = ref.slice('refs/tags/'.length);
|
||||
// Ignore the peeled-annotation entry `refs/tags/<tag>^{}` — same tag, not a distinct version.
|
||||
if (tag.endsWith('^{}')) continue;
|
||||
if (tag.startsWith('v')) tag = tag.slice(1);
|
||||
// Keep only stable x.y.z triplets — a prerelease/junk tag is not an "available stable version".
|
||||
if (!semverMod.isStableTripletSemver(tag)) continue;
|
||||
if (best === null || semverMod.compareSemverCore(tag, best) > 0) best = tag;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
const NPM_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
||||
|
||||
/**
|
||||
* #1463: split an npm package spec (the `parsed.target` for an `npm:` source) into its package NAME and
|
||||
* its trailing version/range selector. The selector is everything after the `@` that separates name from
|
||||
* version — for a SCOPED package (`@scope/name@<sel>`) that is the LAST `@`, NOT the leading scope `@`;
|
||||
* for an unscoped package (`name@<sel>`) it is the single non-leading `@`. A spec with no such `@`
|
||||
* (`@scope/name`, `name`) has selector `''` (tracks the npm `latest` dist-tag).
|
||||
*
|
||||
* Pure string parse on an already-shell-safe spec (assertSafeNpmSpec ran in parseSpec). Used ONLY to
|
||||
* classify the recorded source (exact-pin vs range vs latest) and to range-filter `npm view` output —
|
||||
* the subprocess invocation still passes the FULL `parsed.target` unchanged.
|
||||
*/
|
||||
function splitNpmSpec(target: string): { name: string; selector: string } {
|
||||
// Find the `@` that introduces the version selector: search from the END, but stop at index 0 (the
|
||||
// leading `@` of a scope is never a version separator).
|
||||
const at = target.lastIndexOf('@');
|
||||
if (at <= 0) return { name: target, selector: '' };
|
||||
return { name: target.slice(0, at), selector: target.slice(at + 1) };
|
||||
}
|
||||
|
||||
/**
|
||||
* #1463: pull the ONE canonical version token out of a single `npm view <spec> version` output line, or
|
||||
* null when the line carries no version in a canonical position. UNTRUSTED-DATA RULE: the line is data.
|
||||
*
|
||||
* #1463 Fix 1 (R Medium): the version MUST come from its CANONICAL position, NOT from "any x.y.z token on
|
||||
* the line" — a package NAME can itself contain a version-like substring (`@scope/cap-1.2.3@1.0.0`) and
|
||||
* the old any-token scan returned the NAME's `1.2.3` instead of the resolved `1.0.0`. npm prints lines
|
||||
* shaped `<name>@<version> '<version>'` (range/multi-match) or a single bare `<version>` token (latest
|
||||
* dist-tag). Canonical extraction:
|
||||
* 1. Prefer the QUOTED token (`'x.y.z'`) when present — that is npm's explicit version annotation.
|
||||
* 2. Else take the token after the LAST `@` of the leading `name@version` segment (the first
|
||||
* whitespace-delimited field), mirroring splitNpmSpec's scoped last-`@` rule so a scope `@` is not
|
||||
* mistaken for the version separator.
|
||||
* 3. Else (a single bare line with no `@` and no quotes) treat the whole first field as the version.
|
||||
* The candidate is validated against NPM_VERSION_RE; a version-like substring embedded in the NAME is
|
||||
* never consulted. Returns the canonical version (unvalidated against any range) or null. Pure.
|
||||
*/
|
||||
function extractNpmLineVersion(line: string): string | null {
|
||||
// 1. Quoted annotation `'x.y.z'` — npm's explicit version field.
|
||||
const quoted = line.match(/'([^']+)'/);
|
||||
if (quoted && NPM_VERSION_RE.test(quoted[1])) return quoted[1];
|
||||
|
||||
// 2/3. The leading `name@version` (or bare `version`) field is the first whitespace-delimited token.
|
||||
const head = line.split(/\s+/, 1)[0];
|
||||
if (head === undefined || head === '') return null;
|
||||
// Last `@` that is not a leading scope `@` (index 0) separates name from version; no such `@` ⇒ the
|
||||
// whole head is the candidate (a bare `version` line). Never read a substring inside the NAME portion.
|
||||
const at = head.lastIndexOf('@');
|
||||
const candidate = at > 0 ? head.slice(at + 1) : head;
|
||||
return NPM_VERSION_RE.test(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1463: return the HIGHEST version across `npm view <spec> version` stdout that satisfies `range`
|
||||
* (compareSemverCore for max; semverSatisfies for the range bound), or null when none parse / match.
|
||||
* UNTRUSTED-DATA RULE: the output is treated purely as data. npm prints ONE annotated line per matching
|
||||
* version for a multi-version range, e.g.:
|
||||
* @org/pkg@1.0.0 '1.0.0'
|
||||
* @org/pkg@1.10.0 '1.10.0'
|
||||
* and a single bare line for a single match. Each line yields at most ONE canonical version (via
|
||||
* extractNpmLineVersion — Fix 1: the version's canonical position, NOT any token, so a version-like
|
||||
* substring in the package NAME never poisons the result). We keep only versions satisfying the recorded
|
||||
* range and pick the numeric max so 1.10.0 beats 1.2.0. An empty selector means "no range constraint"
|
||||
* (track latest) → every parseable version qualifies. Pure + deterministic.
|
||||
*/
|
||||
function pickHighestNpmVersion(viewOutput: string, range: string): string | null {
|
||||
if (typeof viewOutput !== 'string') return null;
|
||||
let best: string | null = null;
|
||||
for (const rawLine of viewOutput.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (line === '') continue;
|
||||
const tok = extractNpmLineVersion(line);
|
||||
if (tok === null) continue;
|
||||
// Empty range = no constraint (track latest); otherwise the version must satisfy the recorded range.
|
||||
if (range !== '' && !semverMod.semverSatisfies(tok, range)) continue;
|
||||
if (best === null || semverMod.compareSemverCore(tok, best) > 0) best = tok;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1463 Fix 2 (R Medium): classify the git ref FRAGMENT (`parsed.ref`, the raw text after `#`) by KIND.
|
||||
* parseSpec captures the WHOLE `#…` fragment as a raw string and does NOT split the kind, so we parse the
|
||||
* `sha:` / `tag:` prefix here. The kind decides mutability:
|
||||
* - 'sha' (`#sha:<commit>`) → IMMUTABLE pin (a commit never moves).
|
||||
* - 'tag' (`#tag:<name>`) → IMMUTABLE pin (a tag is opted-into; `update` re-checks-out the SAME tag).
|
||||
* - 'bare' (`#<ref>`) → AMBIGUOUS: it is either a tag (immutable) or a branch (MUTABLE). The
|
||||
* caller must resolve it remotely (git ls-remote <url> <ref>) before
|
||||
* deciding pinned-vs-not — a bare branch ref is NEVER pinned.
|
||||
* - 'none' → no `#<ref>`: tracks the default branch (peek highest tag).
|
||||
* Pure string parse on an already-shell-safe ref (parseSpec asserted it). `sha:`/`tag:` are matched
|
||||
* case-insensitively with optional surrounding whitespace; the prefix's value is returned for diagnostics.
|
||||
*/
|
||||
function classifyGitRef(parsed: ParsedSpec): { kind: 'none' | 'sha' | 'tag' | 'bare'; value: string } {
|
||||
const raw = typeof parsed.ref === 'string' ? parsed.ref.trim() : '';
|
||||
if (raw === '') return { kind: 'none', value: '' };
|
||||
const shaMatch = /^sha:(.+)$/i.exec(raw);
|
||||
if (shaMatch) return { kind: 'sha', value: shaMatch[1].trim() };
|
||||
const tagMatch = /^tag:(.+)$/i.exec(raw);
|
||||
if (tagMatch) return { kind: 'tag', value: tagMatch[1].trim() };
|
||||
return { kind: 'bare', value: raw };
|
||||
}
|
||||
|
||||
/**
|
||||
* #1463 Fix 2 (R Medium): resolve a bare ambiguous git ref to its KIND at the remote with a bounded
|
||||
* `git ls-remote <url> <ref>` (the SAME safe seam as the tag peek: argv + `--`, never a shell string).
|
||||
* ls-remote prints `<sha>\t<full-ref>` lines for every matching ref. A ref that matches under
|
||||
* `refs/tags/` is an immutable TAG; one under `refs/heads/` is a MUTABLE branch. UNTRUSTED-DATA RULE:
|
||||
* the remote's ref strings are data — we only test the canonical `refs/tags/` vs `refs/heads/` prefix on
|
||||
* the ref column (last whitespace-delimited token of each line). Returns:
|
||||
* 'tag' — at least one matching ref under refs/tags/ (and none ambiguous-conflicting branch).
|
||||
* 'branch' — at least one matching ref under refs/heads/.
|
||||
* 'unknown' — ls-remote error / timeout / non-zero / empty / unresolvable / conflicting output.
|
||||
* NEVER throws (DEGRADE). Bounded by PEEK_GIT_TIMEOUT_MS (≤30s).
|
||||
*/
|
||||
function classifyBareGitRefRemote(
|
||||
url: string,
|
||||
ref: string,
|
||||
execGit: (args: string[], o?: { timeout?: number }) => SpawnResult,
|
||||
): 'tag' | 'branch' | 'unknown' {
|
||||
let r: SpawnResult;
|
||||
try {
|
||||
// Metadata-only ref lookup; `--` terminates options so a hostile URL/ref can't be read as a flag
|
||||
// (both are already transport-/shell-safe via parseSpec). The ref filters ls-remote server-side.
|
||||
r = execGit(['ls-remote', '--', url, ref], { timeout: PEEK_GIT_TIMEOUT_MS });
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
if (!r || r.exitCode !== 0 || r.signal) return 'unknown';
|
||||
let sawTag = false;
|
||||
let sawBranch = false;
|
||||
for (const rawLine of (r.stdout || '').split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (line === '') continue;
|
||||
const tabIdx = line.indexOf('\t');
|
||||
const refName = tabIdx === -1 ? line : line.slice(tabIdx + 1).trim();
|
||||
if (refName.startsWith('refs/tags/')) sawTag = true;
|
||||
else if (refName.startsWith('refs/heads/')) sawBranch = true;
|
||||
}
|
||||
// A clean single-kind resolution wins; anything ambiguous (both, or neither) degrades to unknown so a
|
||||
// mutable branch is never silently treated as an immutable tag (and vice-versa).
|
||||
if (sawTag && !sawBranch) return 'tag';
|
||||
if (sawBranch && !sawTag) return 'branch';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* #1463: resolve the LATEST available version for a recorded capability source string, per ADR-1244 D6
|
||||
* ("Update available?" is a per-source matrix). This is a LIGHT remote PEEK — metadata only — never a
|
||||
* re-clone / re-pack / re-extract. It NEVER throws: every error / timeout / unsupported source DEGRADES
|
||||
* to a status the `outdated` verb can render. Per-kind behaviour:
|
||||
*
|
||||
* - git `git ls-remote --tags <url>` → highest stable semver tag (pickHighestSemverTag). status 'ok'.
|
||||
* - npm `npm view <pkg> version` (latest dist-tag) → the reported version. status 'ok'.
|
||||
* - local re-read capability.json at the path (bounded reader) → its `version`. status 'ok'.
|
||||
* - tarball one immutable URL, not auto-detectable per D6 → status 'manual' (no version).
|
||||
* - registry resolveCapabilitySource throws (unimplemented) → status 'unsupported'.
|
||||
*
|
||||
* BOUNDED SUBPROCESSES (CONTEXT.md): git ls-remote ≤30s, npm view ≤60s; on timeout / non-zero / error /
|
||||
* empty-or-unparseable output → status 'unknown' (DEGRADE, never crash the verb).
|
||||
*
|
||||
* The exec seam mirrors the resolver: opts.execOverrides.{git,npm} (or the default shell seam) so a test
|
||||
* can mock the remote PEEK with no network I/O.
|
||||
*/
|
||||
function peekLatestVersion(
|
||||
source: string,
|
||||
opts: {
|
||||
execOverrides?: { git?: (args: string[], o?: { timeout?: number }) => SpawnResult; npm?: (args: string[], o?: { timeout?: number }) => SpawnResult };
|
||||
} = {},
|
||||
): PeekResult {
|
||||
let parsed: ParsedSpec;
|
||||
try {
|
||||
parsed = parseSpec(source);
|
||||
} catch (err) {
|
||||
// An unparseable recorded source cannot be peeked — DEGRADE (do not throw).
|
||||
return { status: 'unknown', version: null, reason: `unparseable source: ${(err as Error).message}` };
|
||||
}
|
||||
|
||||
switch (parsed.kind) {
|
||||
case 'git': {
|
||||
const execGit = opts.execOverrides?.git ?? shellSeam.execGit;
|
||||
// #1463 Fix 2 (R Medium): classify the recorded ref by KIND before deciding pinned-vs-not. An
|
||||
// IMMUTABLE pin (`#sha:`/`#tag:`) is NEVER outdated — `update` re-resolves the SAME commit/tag, so
|
||||
// a newer remote tag is irrelevant; report 'pinned' WITHOUT any peek. A BARE `#<ref>` is ambiguous
|
||||
// (tag OR branch): we MUST classify it remotely so a MUTABLE branch is never falsely 'pinned'.
|
||||
const refKind = classifyGitRef(parsed);
|
||||
if (refKind.kind === 'sha' || refKind.kind === 'tag') {
|
||||
return { status: 'pinned', version: null, reason: `git source pinned to ${refKind.kind} "${refKind.value}"; update will not move it` };
|
||||
}
|
||||
if (refKind.kind === 'bare') {
|
||||
// Resolve the ambiguous ref at the remote (same safe execGit seam: argv + `--`).
|
||||
const resolved = classifyBareGitRefRemote(parsed.target, refKind.value, execGit);
|
||||
if (resolved === 'tag') {
|
||||
// An immutable tag → pinned (the ref the user recorded is a tag, not a moving branch).
|
||||
return { status: 'pinned', version: null, reason: `git source ref "${refKind.value}" resolves to an immutable tag; update will not move it` };
|
||||
}
|
||||
// A branch (MUTABLE) or an unresolvable/ambiguous result. The ledger records NO installed commit
|
||||
// sha for git sources (integrity is null), so a moved branch HEAD cannot be compared against the
|
||||
// installed commit → DEGRADE to 'unknown'. The HARD INVARIANT holds: a branch is NEVER 'pinned'.
|
||||
const reason = resolved === 'branch'
|
||||
? `git source tracks mutable branch "${refKind.value}"; no installed commit recorded to compare against`
|
||||
: `git source ref "${refKind.value}" could not be classified (tag vs branch) at the remote`;
|
||||
return { status: 'unknown', version: null, reason };
|
||||
}
|
||||
// refKind.kind === 'none' — no `#<ref>`, tracks the default branch: peek the highest remote tag.
|
||||
let r: SpawnResult;
|
||||
try {
|
||||
// Metadata-only: ls-remote lists refs without cloning. `--` terminates options so a hostile
|
||||
// URL cannot be read as a flag (the URL is already transport-allowlisted by parseSpec).
|
||||
r = execGit(['ls-remote', '--tags', '--', parsed.target], { timeout: PEEK_GIT_TIMEOUT_MS });
|
||||
} catch (err) {
|
||||
return { status: 'unknown', version: null, reason: `git ls-remote error: ${(err as Error).message}` };
|
||||
}
|
||||
if (!r || r.exitCode !== 0 || r.signal) {
|
||||
const reason = r && r.signal ? `git ls-remote timed out (signal ${r.signal})`
|
||||
: `git ls-remote exit ${r ? r.exitCode : 'n/a'}`;
|
||||
return { status: 'unknown', version: null, reason };
|
||||
}
|
||||
const latest = pickHighestSemverTag(r.stdout || '');
|
||||
if (latest === null) return { status: 'unknown', version: null, reason: 'no semver tags at remote' };
|
||||
return { status: 'ok', version: latest };
|
||||
}
|
||||
case 'npm': {
|
||||
// #1463: classify the recorded npm spec — what would `update` resolve it to?
|
||||
// exact version (`@1.2.3`) → PINNED: update re-installs the SAME version, never outdated.
|
||||
// range (`@^1`, `@~1.2`, …) → peek and pick the HIGHEST version satisfying the range (multi-line).
|
||||
// no version (bare name) → peek the single `latest` dist-tag version.
|
||||
const { selector } = splitNpmSpec(parsed.target);
|
||||
// An EXACT version selector is an immutable pin (a single x.y.z[-pre], no range operator/wildcard).
|
||||
if (selector !== '' && NPM_VERSION_RE.test(selector)) {
|
||||
return { status: 'pinned', version: selector, reason: `npm source pinned to exact version "${selector}"; update will not move it` };
|
||||
}
|
||||
const execNpm = opts.execOverrides?.npm ?? shellSeam.execNpm;
|
||||
let r: SpawnResult;
|
||||
try {
|
||||
// Mirrors scripts/check-latest-version.cjs (checkLatestVersion): `npm view <spec> version` reports
|
||||
// the matching version(s). parsed.target is the npm package spec (parseSpec asserted it is free of
|
||||
// shell metacharacters) and is passed UNCHANGED — for a range npm prints every matching version,
|
||||
// for a bare name the single latest. `--` terminates options.
|
||||
r = execNpm(['view', '--', parsed.target, 'version'], { timeout: PEEK_NPM_TIMEOUT_MS });
|
||||
} catch (err) {
|
||||
return { status: 'unknown', version: null, reason: `npm view error: ${(err as Error).message}` };
|
||||
}
|
||||
if (!r || r.exitCode !== 0 || r.signal) {
|
||||
const reason = r && r.signal ? `npm view timed out (signal ${r.signal})`
|
||||
: `npm view exit ${r ? r.exitCode : 'n/a'}`;
|
||||
return { status: 'unknown', version: null, reason };
|
||||
}
|
||||
// Treat the OUTPUT as untrusted: extract every version token (npm prints one annotated line per
|
||||
// matching version for a range, a bare token for a single match) and pick the HIGHEST that
|
||||
// satisfies the recorded range (empty selector = no constraint → latest). Garbage / no match →
|
||||
// DEGRADE to 'unknown'.
|
||||
const version = pickHighestNpmVersion(r.stdout || '', selector);
|
||||
if (version === null) {
|
||||
return { status: 'unknown', version: null, reason: `npm view returned no matching semver version: ${(r.stdout || '').trim() || '(empty)'}` };
|
||||
}
|
||||
return { status: 'ok', version };
|
||||
}
|
||||
case 'local': {
|
||||
// Re-read the recorded local capability.json (bounded reader) for its current declared version.
|
||||
let cap: Record<string, unknown>;
|
||||
try {
|
||||
const manifestPath = path.join(path.resolve(parsed.target), 'capability.json');
|
||||
cap = readManifestBounded(manifestPath, `local capability.json not readable: ${parsed.target}`);
|
||||
} catch (err) {
|
||||
return { status: 'unknown', version: null, reason: `local re-read failed: ${(err as Error).message}` };
|
||||
}
|
||||
const version = typeof cap['version'] === 'string' ? cap['version'] : '';
|
||||
if (!version) return { status: 'unknown', version: null, reason: 'local capability.json missing version' };
|
||||
return { status: 'ok', version };
|
||||
}
|
||||
case 'tarball':
|
||||
// D6: a bare tarball URL is one immutable artifact — there is no catalogue to query, so update
|
||||
// availability cannot be auto-detected. Surface 'manual' (the user must point install at a new URL).
|
||||
return { status: 'manual', version: null, reason: 'tarball sources cannot be auto-checked; re-install from a new URL' };
|
||||
case 'registry':
|
||||
// The registry adapter is unimplemented (resolveCapabilitySource throws for it).
|
||||
return { status: 'unsupported', version: null, reason: 'registry source kind is not yet implemented' };
|
||||
default: {
|
||||
const _never: never = parsed.kind;
|
||||
return { status: 'unknown', version: null, reason: `unknown source kind: ${String(_never)}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1101,8 +1454,16 @@ export = {
|
||||
// #1461 finding 3 test seam: the exact bounded reader stageValidated uses on the COPIED manifest, so
|
||||
// a test can exercise the staged re-read directly (not just the local pre-read that shadows it).
|
||||
_readManifestBounded: readManifestBounded,
|
||||
// #1463: D6 "Update available?" per-source latest-version peek + the pure parsers it composes (the
|
||||
// git highest-semver-tag parser, the npm spec splitter, and the npm-view range/version picker).
|
||||
peekLatestVersion,
|
||||
pickHighestSemverTag,
|
||||
splitNpmSpec,
|
||||
pickHighestNpmVersion,
|
||||
MAX_RESPONSE_BYTES,
|
||||
MANIFEST_MAX_BYTES,
|
||||
MAX_STAGED_BUNDLE_BYTES,
|
||||
MAX_STAGED_BUNDLE_ENTRIES,
|
||||
PEEK_GIT_TIMEOUT_MS,
|
||||
PEEK_NPM_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user