New read-only leaf src/install-shadow-report.cts turns Phase 3's resolveInstalledSurfaces output into a typed shadow-report IR plus a line-array renderer, with declaredRuntime sanitized at the render seam (ANSI, C0/C1, newlines, bidi overrides; idempotent, no second truncation over the reader's 64-char cap). Also closes the symlink asymmetry the resolver carried: the local scope resolves against process.cwd(), and this phase is what makes that path reachable from an arbitrary cloned repository, so the manifest read is now lstat-guarded rather than following. Matches the getAgentsDir precedent and degrades to the same installed:false shape the EACCES path already returned. Refs #2873
491 lines
22 KiB
TypeScript
491 lines
22 KiB
TypeScript
/**
|
|
* installed-surface-resolver.cts — Installed Surface Resolver Module (#2872,
|
|
* ADR-2866, Phase 3 — governed by
|
|
* `.gsd/phase/feat-2872-manifest-scope-runtime/40-design.md`).
|
|
*
|
|
* `resolveInstalledSurfaces(runtime?, opts?)` answers a question no existing
|
|
* module answers: "which surfaces actually exist on THIS machine, across
|
|
* BOTH install scopes, right now?" `capability-state.cts` answers "which
|
|
* capabilities are enabled in THIS config dir" — a different question at a
|
|
* different altitude. This module is a new leaf: it reads two scopes at
|
|
* once, composing three already-shipped, already-tested pieces
|
|
* (`resolveScope`, Phase 1; `resolveTriggerSurface`, Phase 2; and
|
|
* `readInstallManifest`, widened in this same phase) rather than
|
|
* re-implementing any of their rules.
|
|
*
|
|
* ── Probe-not-declared keying ───────────────────────────────────────────────
|
|
* A scope RECORD is keyed by the PROBED scope (`resolveScope`'s own `id`),
|
|
* never by what the manifest itself claims. `manifestVersion`/`runtime`/
|
|
* `scope` recorded inside a v2 manifest are corroboration, not identity: a
|
|
* manifest copied between config dirs, or a `--config-dir` install, must
|
|
* still be reported at the scope this machine actually resolves to.
|
|
*
|
|
* ── Report, don't correct ───────────────────────────────────────────────────
|
|
* When a declared `runtime`/`scope` disagrees with the probe, this module
|
|
* reports the mismatch (`declaredScopeMatchesProbe: false` /
|
|
* `declaredRuntimeMatchesProbe: false`) and otherwise proceeds exactly as it
|
|
* would for an undeclared (v1) manifest. It never silently substitutes the
|
|
* declared value for the probed one, and never throws on a mismatch — see
|
|
* B-row "Postel's Law" discussion in the design doc.
|
|
*
|
|
* ── One trigger call over installed scopes only ─────────────────────────────
|
|
* `resolveTriggerSurface` is called AT MOST once per runtime, with the
|
|
* scopes that are actually installed on this machine (per manifest
|
|
* presence — never per the new corroboration fields, which is what keeps a
|
|
* v1-only install fully functional with no reinstall required). A
|
|
* hypothetical "what if both scopes were installed" answer is deliberately
|
|
* not offered; #2218 is a fact about THIS machine, not a simulation.
|
|
*
|
|
* ── Installed-ness is manifest PRESENCE, never the new fields ───────────────
|
|
* `installed` is `manifestVersion !== null`. A v1 manifest (no
|
|
* `manifestVersion` key at all, `manifestVersion: 1` after normalization) is
|
|
* a correct manifest written by an older GSD — never a broken one, never
|
|
* grounds for `installed: false`, a warning, or a reinstall prompt.
|
|
*
|
|
* ── Stems come from the manifest, not the source tree ───────────────────────
|
|
* `resolveTriggerSurface` needs `stems`. They are derived from the
|
|
* INSTALLED manifest's own `files` keys (see the private stem-derivation
|
|
* helpers below), never from a roster read of the source tree — a global
|
|
* `claude` install ships no `commands/gsd` source, so a roster read would
|
|
* return `[]` in exactly the configuration #2218 is about. The derivation
|
|
* consumes `isNamespacedByDir` and `composeCommandFilename`
|
|
* (`runtime-artifact-layout.cjs`, #2871 Phase 2's exported helpers) rather
|
|
* than re-deriving either rule as a fourth independent copy.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { resolveScope, SCOPE_ORDER, type InstallScope } from './install-scope.cjs';
|
|
import { posixNormalize } from './shell-command-projection.cjs';
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import runtimeArtifactLayoutMod = require('./runtime-artifact-layout.cjs');
|
|
const {
|
|
resolveRuntimeArtifactLayout,
|
|
resolveRuntimeArtifactLayoutFromRegistry,
|
|
resolveTriggerSurface,
|
|
isNamespacedByDir,
|
|
composeCommandFilename,
|
|
} = runtimeArtifactLayoutMod;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import installerMigrationsMod = require('./installer-migrations.cjs');
|
|
const { readInstallManifest, MANIFEST_NAME } = installerMigrationsMod;
|
|
|
|
// In .cts (CommonJS output) files, `require` is available as a global.
|
|
const _require: NodeRequire = require;
|
|
|
|
// ── Types derived from the two composed modules ────────────────────────────
|
|
//
|
|
// Neither `TriggerSurface` nor the two modules' private registry shapes are
|
|
// exported by name from `runtime-artifact-layout.cts` (only the functions
|
|
// are, via its `export =`). Deriving the types with `ReturnType`/
|
|
// `Parameters` off the imported function values — rather than duplicating
|
|
// the shapes here as a second, driftable copy — is the same pattern already
|
|
// used elsewhere in this codebase (e.g. `phase.cts`, `config.cts`).
|
|
|
|
/** One resolved `/gsd-<name>`-style trigger, as `resolveTriggerSurface`
|
|
* (`runtime-artifact-layout.cts`, #2871 Phase 2) produces it. */
|
|
type TriggerSurface = ReturnType<typeof resolveTriggerSurface>[number];
|
|
|
|
/** The registry shape `resolveRuntimeArtifactLayoutFromRegistry` accepts as
|
|
* its first argument — reused so `opts.registry` can be forwarded to it
|
|
* without a second, independently-typed registry shape. */
|
|
type LayoutRegistryLike = Parameters<typeof resolveRuntimeArtifactLayoutFromRegistry>[0];
|
|
|
|
/** The registry shape `resolveTriggerSurface`'s `opts.registry` accepts. */
|
|
type TriggerRegistryLike = NonNullable<Parameters<typeof resolveTriggerSurface>[2]['registry']>;
|
|
|
|
/** Minimal shape this module needs to enumerate registered runtime ids
|
|
* (C7) — deliberately narrower than `LayoutRegistryLike`/`TriggerRegistryLike`
|
|
* above (`Object.keys` needs nothing more than the `runtimes` map itself). */
|
|
interface RuntimeEnumerableRegistry {
|
|
runtimes: Record<string, unknown>;
|
|
}
|
|
|
|
// ── Public types ────────────────────────────────────────────────────────
|
|
|
|
export interface InstalledScopeRecord {
|
|
scope: InstallScope;
|
|
configHome: string;
|
|
installed: boolean;
|
|
manifestVersion: number | null;
|
|
declaredRuntime: string | null;
|
|
declaredScope: InstallScope | null;
|
|
/** `null` when nothing was declared (v1 manifest or not installed). */
|
|
declaredScopeMatchesProbe: boolean | null;
|
|
declaredRuntimeMatchesProbe: boolean | null;
|
|
/** Trigger stems derived from this scope's manifest keys, sorted, deduped. */
|
|
stems: string[];
|
|
}
|
|
|
|
export interface InstalledRuntimeSurface {
|
|
runtime: string;
|
|
scopes: InstalledScopeRecord[];
|
|
/** Resolved across ONLY the installed scopes, in one resolveTriggerSurface
|
|
* call, so `shadowedBy` describes THIS machine rather than a hypothetical. */
|
|
triggers: TriggerSurface[];
|
|
}
|
|
|
|
export interface ResolveInstalledSurfacesOptions {
|
|
home?: string;
|
|
cwd?: string;
|
|
env?: Record<string, string | undefined>;
|
|
existsSync?: (p: string) => boolean;
|
|
/** Injected for tests, matching `existsSync` above; defaults to
|
|
* `node:fs`'s `lstatSync`. Used to refuse a manifest read when the
|
|
* scope's config dir or manifest file is a symlink — see the
|
|
* `buildScopeRecord` comment for why this is a deliberate hardening,
|
|
* not an oversight. */
|
|
lstatSync?: (p: string) => { isSymbolicLink(): boolean };
|
|
registry?: unknown;
|
|
/** Injected for tests; defaults to installer-migrations' readInstallManifest. */
|
|
readManifest?: (configDir: string) => {
|
|
manifestVersion: number | null;
|
|
runtime: string | null;
|
|
scope: InstallScope | null;
|
|
files: Record<string, string>;
|
|
};
|
|
}
|
|
|
|
// ── Internals ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* A trigger stem is a kebab-case token and nothing else. Verified against the
|
|
* real roster: all 71 `commands/gsd/*.md` stems match this, so it has no false
|
|
* negatives today.
|
|
*
|
|
* This is a SECURITY boundary, not cosmetics. A stem is derived from a manifest
|
|
* key — attacker-influenceable text, since a project-local
|
|
* `gsd-file-manifest.json` lives inside a repository a user may merely have
|
|
* cloned — and Phase 4 (#2873) renders it back to the user as `/gsd-<stem>`.
|
|
* Without this, `skills/gsd-../../../x` yields the stem `..`, and control
|
|
* characters, newlines, ANSI escapes and RTL-override codepoints all survive
|
|
* into that rendered trigger. The `commands` branch happened to be protected by
|
|
* its `composeCommandFilename` round-trip; the `skills` branch had no
|
|
* equivalent, so the rule is stated once here and applied to both.
|
|
*/
|
|
const SAFE_STEM = /^[a-z0-9][a-z0-9-]*$/;
|
|
|
|
function getDefaultRuntimeRegistry(): RuntimeEnumerableRegistry {
|
|
return _require('./capability-registry.cjs') as RuntimeEnumerableRegistry;
|
|
}
|
|
|
|
/** C7: every registered runtime id, sorted for determinism. Reads
|
|
* `opts.registry` when provided (so a sweep is assertable without the real
|
|
* capability registry), else the real one. */
|
|
function listRegisteredRuntimeIds(opts: ResolveInstalledSurfacesOptions): string[] {
|
|
const registry = (opts.registry as RuntimeEnumerableRegistry | undefined) ?? getDefaultRuntimeRegistry();
|
|
return Object.keys(registry.runtimes).sort();
|
|
}
|
|
|
|
/**
|
|
* D — the stem inverse for one `commands`/`skills` layout kind entry.
|
|
* Consumes `isNamespacedByDir` (to decide the commands filename shape) and
|
|
* `composeCommandFilename` (to VERIFY a candidate stem round-trips to the
|
|
* exact filename seen) rather than re-deriving either rule locally (design
|
|
* "Rejected" #6). Manifest keys are normalized with an unconditional
|
|
* `.replace(/\\/g, '/')` (D7) — never gated on `path.sep` — matching this
|
|
* repo's recorded path-separator-normalization defect class.
|
|
*/
|
|
function deriveStemsForKindEntry(
|
|
kind: 'commands' | 'skills',
|
|
destSubpath: string,
|
|
prefix: string,
|
|
fileKeys: readonly string[],
|
|
): string[] {
|
|
const destSubpathNorm = posixNormalize(destSubpath);
|
|
const boundary = `${destSubpathNorm}/`;
|
|
const namespacedByDir = isNamespacedByDir(kind, destSubpath, prefix);
|
|
const stems = new Set<string>();
|
|
|
|
for (const rawKey of fileKeys) {
|
|
const key = rawKey.replace(/\\/g, '/');
|
|
if (!key.startsWith(boundary)) continue; // D4: key not under this declared subpath
|
|
const rest = key.slice(boundary.length);
|
|
if (rest === '') continue;
|
|
const segments = rest.split('/');
|
|
|
|
if (kind === 'skills') {
|
|
const dirSegment = segments[0];
|
|
if (!dirSegment.startsWith(prefix)) continue; // D5: subpath matches, prefix does not
|
|
const stem = dirSegment.slice(prefix.length);
|
|
if (stem === '') continue; // D7/B7: empty stem never emitted
|
|
if (!SAFE_STEM.test(stem)) continue; // security: reject anything but a kebab-case token
|
|
stems.add(stem); // D6: several files under one dir -> one stem
|
|
continue;
|
|
}
|
|
|
|
// commands: exactly one path segment — the composed filename itself.
|
|
if (segments.length !== 1) continue;
|
|
const filename = segments[0];
|
|
if (!filename.endsWith('.md')) continue;
|
|
const base = filename.slice(0, -3);
|
|
const candidateStem = namespacedByDir
|
|
? base
|
|
: (base.startsWith(prefix) ? base.slice(prefix.length) : '');
|
|
if (candidateStem === '') continue;
|
|
if (composeCommandFilename(namespacedByDir, prefix, candidateStem) !== filename) continue;
|
|
if (!SAFE_STEM.test(candidateStem)) continue; // security: reject anything but a kebab-case token
|
|
stems.add(candidateStem);
|
|
}
|
|
|
|
return [...stems];
|
|
}
|
|
|
|
/** D — full stem set for one scope: every `commands`/`skills` layout kind
|
|
* entry, unioned. Empty `files` (C13) short-circuits to `[]` without
|
|
* resolving a layout at all. */
|
|
function deriveStemsFromManifest(
|
|
runtime: string,
|
|
scopeId: InstallScope,
|
|
configHome: string,
|
|
files: Record<string, string>,
|
|
opts: ResolveInstalledSurfacesOptions,
|
|
): string[] {
|
|
const fileKeys = Object.keys(files);
|
|
if (fileKeys.length === 0) return [];
|
|
|
|
const layout = opts.registry !== undefined
|
|
? resolveRuntimeArtifactLayoutFromRegistry(opts.registry as LayoutRegistryLike, runtime, configHome, scopeId)
|
|
: resolveRuntimeArtifactLayout(runtime, configHome, scopeId);
|
|
|
|
const stems = new Set<string>();
|
|
for (const kindEntry of layout.kinds) {
|
|
if (kindEntry.kind !== 'commands' && kindEntry.kind !== 'skills') continue; // excludes agents/kimi-agents (D4)
|
|
for (const stem of deriveStemsForKindEntry(kindEntry.kind, kindEntry.destSubpath, kindEntry.prefix, fileKeys)) {
|
|
stems.add(stem);
|
|
}
|
|
}
|
|
return [...stems].sort();
|
|
}
|
|
|
|
/**
|
|
* True when `p` is a symlink. An `lstatSync` throw (ENOENT — nothing at this
|
|
* path) is NOT evidence of a symlink; it is treated as "not a symlink" here
|
|
* and left for `readManifest` to classify (it already owns the absent-file
|
|
* case, per C14 above).
|
|
*/
|
|
function isSymlinkPath(p: string, lstatSync: (p: string) => { isSymbolicLink(): boolean }): boolean {
|
|
try {
|
|
return lstatSync(p).isSymbolicLink();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The shared "not installed" degraded shape (C14's EACCES path, and this
|
|
* phase's new symlink-guard path). A FACTORY, not a module-level constant
|
|
* object: a `const` object spread at each return site would still share the
|
|
* same `stems` ARRAY reference across every call (`...` shallow-copies the
|
|
* object but not the array a property points at), which would violate this
|
|
* module's own "builds fresh arrays/objects on every call" contract (C15) —
|
|
* a caller mutating one degraded record's `stems` must never be visible on
|
|
* another's.
|
|
*/
|
|
function notInstalledScopeRecordFields(): Pick<
|
|
InstalledScopeRecord,
|
|
'installed' | 'manifestVersion' | 'declaredRuntime' | 'declaredScope' | 'declaredScopeMatchesProbe' | 'declaredRuntimeMatchesProbe' | 'stems'
|
|
> {
|
|
return {
|
|
installed: false,
|
|
manifestVersion: null,
|
|
declaredRuntime: null,
|
|
declaredScope: null,
|
|
declaredScopeMatchesProbe: null,
|
|
declaredRuntimeMatchesProbe: null,
|
|
stems: [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build one scope's record. `resolvedConfigHome` has already been probed
|
|
* successfully by the time this is called (a `resolveScope` `TypeError` is
|
|
* handled by the caller, per C8 — it is never this function's concern).
|
|
*
|
|
* Two distinct failure modes are handled with two distinct, narrowly-scoped
|
|
* try/catches — NOT one catch-all around the whole function:
|
|
*
|
|
* - The manifest READ (`readManifest`) is the only thing that can justify
|
|
* `installed: false` (C14, e.g. EACCES). A failure here means "we could
|
|
* not even tell whether this scope is installed".
|
|
* - The stem DERIVATION (`deriveStemsFromManifest`, which resolves a
|
|
* runtime artifact layout) is a separate concern. A `TypeError` thrown
|
|
* while deriving stems is a layout-lookup failure, not evidence the
|
|
* manifest is absent — the manifest was already read successfully, so
|
|
* `installed` and every declared/*MatchesProbe field stay exactly as the
|
|
* manifest reported. Conflating the two would report a genuinely
|
|
* installed runtime as `installed: false`, hiding a real install from
|
|
* #2218 shadow detection precisely when this module exists to surface it.
|
|
*/
|
|
function buildScopeRecord(
|
|
runtime: string,
|
|
scopeId: InstallScope,
|
|
resolvedConfigHome: string,
|
|
opts: ResolveInstalledSurfacesOptions,
|
|
): InstalledScopeRecord {
|
|
// Hardening requirement 2 (#2873 design doc, "Hardening requirements
|
|
// claimed from #2873's comment"): `readInstallManifest` -> `readJsonIfPresent`
|
|
// uses `existsSync` + `readFileSync` and therefore FOLLOWS symlinks, and
|
|
// this module resolves `local` against `process.cwd()` — a directory that,
|
|
// as of this phase, becomes reachable from an arbitrary cloned repository
|
|
// (this is the same phase that makes the local scope's manifest a first
|
|
// read target, not merely a write target). The in-tree precedent is
|
|
// `getAgentsDir` (`agent-install-check.cts`), which probes with
|
|
// `fs.lstatSync(...).isDirectory()`/`.isFile()` and deliberately does not
|
|
// follow. #2872 left this resolver's read un-guarded only because the path
|
|
// had zero callers at the time; refusing to follow a symlinked config dir
|
|
// or manifest here closes that asymmetry rather than carrying it forward.
|
|
// Degrading to `installed: false` reuses the SAME shape the EACCES catch
|
|
// below already returns — no new failure shape is introduced.
|
|
const lstatSync = opts.lstatSync ?? fs.lstatSync;
|
|
const manifestPath = path.join(resolvedConfigHome, MANIFEST_NAME);
|
|
if (isSymlinkPath(resolvedConfigHome, lstatSync) || isSymlinkPath(manifestPath, lstatSync)) {
|
|
return { scope: scopeId, configHome: resolvedConfigHome, ...notInstalledScopeRecordFields() };
|
|
}
|
|
|
|
let manifest: {
|
|
manifestVersion: number | null;
|
|
runtime: string | null;
|
|
scope: InstallScope | null;
|
|
files: Record<string, string>;
|
|
};
|
|
try {
|
|
const readManifest = opts.readManifest ?? readInstallManifest;
|
|
manifest = readManifest(resolvedConfigHome);
|
|
} catch {
|
|
// C14: manifest read/probe failure degrades to not-installed, never throws.
|
|
// Deliberately a BARE catch, unlike the stem-derivation catch below: any
|
|
// read failure at all (EACCES, ENOENT-after-race, a corrupt filesystem)
|
|
// legitimately means "cannot tell whether installed" (design row C14), so
|
|
// there is no error TYPE here that should instead propagate.
|
|
return { scope: scopeId, configHome: resolvedConfigHome, ...notInstalledScopeRecordFields() };
|
|
}
|
|
|
|
const installed = manifest.manifestVersion !== null; // C9: presence, never the new fields
|
|
const declaredRuntime = manifest.runtime;
|
|
const declaredScope = manifest.scope;
|
|
const declaredRuntimeMatchesProbe = declaredRuntime === null ? null : declaredRuntime === runtime;
|
|
const declaredScopeMatchesProbe = declaredScope === null ? null : declaredScope === scopeId;
|
|
|
|
let stems: string[] = [];
|
|
if (installed) {
|
|
try {
|
|
stems = deriveStemsFromManifest(runtime, scopeId, resolvedConfigHome, manifest.files, opts);
|
|
} catch (error) {
|
|
if (!(error instanceof TypeError)) throw error;
|
|
// A layout-lookup failure means "we could not enumerate this scope's
|
|
// triggers", NOT "this scope is not installed" — the manifest read
|
|
// already succeeded above, so `installed` and the declared fields
|
|
// stay as reported. Conflating the two would hide a real install.
|
|
// Anything that is not the expected `TypeError` (a genuine bug in
|
|
// `deriveStemsFromManifest`/`resolveRuntimeArtifactLayout`) is
|
|
// rethrown rather than silently degrading to `stems: []` — this
|
|
// matches `resolveInstalledSurfaces`'s own `TypeError` narrowing
|
|
// below, so the two catches cannot drift apart.
|
|
stems = [];
|
|
}
|
|
}
|
|
|
|
return {
|
|
scope: scopeId,
|
|
configHome: resolvedConfigHome,
|
|
installed,
|
|
manifestVersion: manifest.manifestVersion,
|
|
declaredRuntime,
|
|
declaredScope,
|
|
declaredScopeMatchesProbe,
|
|
declaredRuntimeMatchesProbe,
|
|
stems,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve one runtime's full installed surface. A `resolveScope` `TypeError`
|
|
* (unknown runtime, or `configHome.kind === 'none'`, e.g. vscode) propagates
|
|
* from here uncaught — `resolveInstalledSurfaces` decides whether that
|
|
* means "skip" (C7 sweep) or "propagate" (explicit single-runtime ask, C8).
|
|
*/
|
|
function resolveOneRuntime(runtime: string, opts: ResolveInstalledSurfacesOptions): InstalledRuntimeSurface {
|
|
const scopes: InstalledScopeRecord[] = SCOPE_ORDER.map((scopeId) => {
|
|
const resolved = resolveScope({
|
|
id: scopeId,
|
|
runtime,
|
|
env: opts.env,
|
|
home: opts.home,
|
|
existsSync: opts.existsSync,
|
|
cwd: opts.cwd,
|
|
});
|
|
return buildScopeRecord(runtime, scopeId, resolved.configHome, opts);
|
|
});
|
|
|
|
// C12: dedupe by resolved configHome BEFORE the trigger call, keeping the
|
|
// higher-ranked (global, first in SCOPE_ORDER) scope. Both scope RECORDS
|
|
// above are unaffected — only the scope list handed to resolveTriggerSurface
|
|
// is deduped.
|
|
const seenConfigHomes = new Set<string>();
|
|
const triggerScopeIds: InstallScope[] = [];
|
|
for (const record of scopes) {
|
|
if (!record.installed) continue;
|
|
if (seenConfigHomes.has(record.configHome)) continue;
|
|
seenConfigHomes.add(record.configHome);
|
|
triggerScopeIds.push(record.scope);
|
|
}
|
|
|
|
const stemUnion = new Set<string>();
|
|
for (const scopeId of triggerScopeIds) {
|
|
const record = scopes.find((s) => s.scope === scopeId);
|
|
for (const stem of record?.stems ?? []) stemUnion.add(stem);
|
|
}
|
|
|
|
const triggers: TriggerSurface[] = triggerScopeIds.length > 0
|
|
? resolveTriggerSurface(runtime, triggerScopeIds, {
|
|
stems: [...stemUnion].sort(),
|
|
registry: opts.registry as TriggerRegistryLike | undefined,
|
|
})
|
|
: [];
|
|
|
|
return { runtime, scopes, triggers };
|
|
}
|
|
|
|
/**
|
|
* Read-only. Probes both install scopes for a runtime (or every registered
|
|
* runtime, sorted by id, when `runtime` is omitted), reads each scope's
|
|
* manifest, and resolves the trigger surface across the scopes that are
|
|
* actually installed on this machine. See the module-level comment for the
|
|
* non-obvious choices (probe-not-declared keying, report-don't-correct, one
|
|
* trigger call over installed scopes only).
|
|
*
|
|
* Pure with respect to caller-visible state: builds fresh arrays/objects on
|
|
* every call (C15) and performs no writes. Filesystem reads happen only via
|
|
* `resolveScope`'s injected `existsSync`/`env`/`home`/`cwd` and via
|
|
* `readManifest` (default: `readInstallManifest`).
|
|
*
|
|
* @throws {TypeError} when `runtime` is given explicitly and it is unknown,
|
|
* or has no installable config directory (`configHome.kind === 'none'`,
|
|
* e.g. vscode) — same contract `resolveScope` throws. In the all-runtimes
|
|
* sweep (`runtime` omitted), a runtime that would throw this same
|
|
* `TypeError` is skipped instead, so one non-installable runtime cannot
|
|
* kill the sweep (C7/C8). Any other error type is never swallowed here.
|
|
*/
|
|
export function resolveInstalledSurfaces(
|
|
runtime?: string,
|
|
opts: ResolveInstalledSurfacesOptions = {},
|
|
): InstalledRuntimeSurface[] {
|
|
if (typeof runtime === 'string') {
|
|
return [resolveOneRuntime(runtime, opts)];
|
|
}
|
|
|
|
const results: InstalledRuntimeSurface[] = [];
|
|
for (const runtimeId of listRegisteredRuntimeIds(opts)) {
|
|
try {
|
|
results.push(resolveOneRuntime(runtimeId, opts));
|
|
} catch (error) {
|
|
if (error instanceof TypeError) continue; // C8: sweep skips, never dies
|
|
throw error;
|
|
}
|
|
}
|
|
return results;
|
|
}
|