singularity-forge/src/extension-discovery.ts
Mikael Hugo a611cd5792 feat: introduce repo-vcs skill and add JSDoc annotations across core modules
- Add repository-vcs-context.ts to detect and inject VCS context (Git/Jujutsu)
  into the agent system prompt; wire in repo-vcs bundled skill trigger
- Add src/resources/skills/repo-vcs/ skill for commit, push, and safe-push workflows
- Add JSDoc Purpose/Consumer annotations to app-paths, bundled-extension-paths,
  errors, extension-discovery, extension-registry, headless-types, headless, and traces
- Add justfile and just to flake.nix devShell
- Fill out new-user-onboarding.md spec (Draft) and core-beliefs.md (Status: Accepted)
- Add notification-event-model.md design doc and notification-source-hygiene.md spec

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 21:36:32 +02:00

117 lines
3.9 KiB
TypeScript

/**
* Extension Discovery — resolves extension entry-point files from a directory tree.
*
* Supports two discovery modes:
* 1. package.json with a `pi` manifest (authoritative, allows opt-out).
* 2. Fallback to index.ts / index.js when no manifest is present.
*
* Purpose: decouple the physical layout of extensions on disk from the loader so
* that extensions can declare their own entry points and library directories can
* opt out of being loaded.
*
* Consumer: extension-registry.ts (ensureRegistryEntries), the sf-run loader, and
* the test suite that validates symlink and manifest edge cases.
*/
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
function isExtensionFile(name: string): boolean {
return (
(name.endsWith(".ts") && !name.endsWith(".d.ts")) ||
(name.endsWith(".js") && !name.endsWith(".d.js"))
);
}
/**
* Resolves the entry-point file(s) for a single extension directory.
*
* 1. If the directory contains a package.json with a `pi` manifest object,
* the manifest is authoritative:
* - `pi.extensions` array → resolve each entry relative to the directory.
* - `pi: {}` (no extensions) → return empty (library opt-out, e.g. cmux).
* 2. Only when no `pi` manifest exists does it fall back to `index.ts` → `index.js`.
*
* Purpose: give extension authors explicit control over what gets loaded while
* preserving backwards compatibility for simple extensions that only provide an
* index file.
*
* Consumer: discoverExtensionEntryPaths() and tests that verify manifest vs fallback
* resolution logic.
*/
export function resolveExtensionEntries(dir: string): string[] {
const packageJsonPath = join(dir, "package.json");
if (existsSync(packageJsonPath)) {
try {
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
if (pkg?.pi && typeof pkg.pi === "object") {
// When a pi manifest exists, it is authoritative — don't fall through
// to index.ts/index.js auto-detection. This allows library directories
// (like cmux) to opt out by declaring "pi": {} with no extensions.
const declared = pkg.pi.extensions;
if (!Array.isArray(declared) || declared.length === 0) {
return [];
}
return declared
.filter(
(entry: unknown): entry is string => typeof entry === "string",
)
.map((entry: string) => resolve(dir, entry))
.filter((entry: string) => existsSync(entry));
}
} catch {
// Ignore malformed manifests and fall back to index.ts/index.js discovery.
}
}
const indexTs = join(dir, "index.ts");
if (existsSync(indexTs)) {
return [indexTs];
}
const indexJs = join(dir, "index.js");
if (existsSync(indexJs)) {
return [indexJs];
}
return [];
}
/**
* Discovers all extension entry-point paths under an extensions directory.
*
* - Top-level .ts/.js files are treated as standalone extension entry points.
* - Subdirectories are resolved via `resolveExtensionEntries()` (package.json →
* pi.extensions, then index.ts/index.js fallback).
*
* Purpose: produce a flat list of absolute entry-point paths that the loader can
* require() in order, regardless of whether extensions are organised as files or
* directories.
*
* Consumer: the sf-run loader bootstrap and integration tests that verify discovery
* against fixture directories.
*/
export function discoverExtensionEntryPaths(extensionsDir: string): string[] {
if (!existsSync(extensionsDir)) {
return [];
}
const discovered: string[] = [];
for (const entry of readdirSync(extensionsDir, { withFileTypes: true })) {
const entryPath = join(extensionsDir, entry.name);
if (
(entry.isFile() || entry.isSymbolicLink()) &&
isExtensionFile(entry.name)
) {
discovered.push(entryPath);
continue;
}
if (entry.isDirectory() || entry.isSymbolicLink()) {
discovered.push(...resolveExtensionEntries(entryPath));
}
}
return discovered;
}