singularity-forge/src/extension-registry.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

342 lines
12 KiB
TypeScript

/**
* Extension Registry — manages manifest reading, registry persistence, and enable/disable state.
*
* Extensions without manifests always load (backwards compatible).
* A fresh install has an empty registry — all extensions enabled by default.
* The only way an extension stops loading is an explicit `sf extensions disable <id>`.
*
* Purpose: provide a single source of truth for which extensions are active so that
* the loader can decide what to load and the CLI can show the user what is installed.
*
* Consumer: extension-discovery.ts (reads manifests), loader.ts (decides what to load),
* and commands-handlers.ts (implements `sf extensions list/enable/disable`).
*/
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { appRoot } from "./app-paths.js";
// ─── Types ──────────────────────────────────────────────────────────────────
/**
* Describes the static metadata shipped with an extension.
*
* Purpose: let the registry and loader validate an extension before loading it
* and present human-readable information in CLI listings.
*
* Consumer: readManifest(), discoverAllManifests(), and the `sf extensions list` command.
*/
export interface ExtensionManifest {
id: string;
name: string;
version: string;
description: string;
tier: "core" | "bundled" | "community";
requires: { platform: string };
provides?: {
tools?: string[];
commands?: string[];
hooks?: string[];
shortcuts?: string[];
};
dependencies?: {
extensions?: string[];
runtime?: string[];
};
}
/**
* A single entry in the on-disk registry file.
*
* Purpose: persist whether an extension is enabled, why it was disabled, and
* where it came from so that decisions survive process restarts.
*
* Consumer: loadRegistry(), saveRegistry(), and the enable/disable mutations.
*/
export interface ExtensionRegistryEntry {
id: string;
enabled: boolean;
source: "bundled" | "user" | "project";
disabledAt?: string;
disabledReason?: string;
}
/**
* The top-level shape of the persisted registry file.
*
* Purpose: version the JSON schema so future migrations can detect old formats.
*
* Consumer: loadRegistry() and saveRegistry().
*/
export interface ExtensionRegistry {
version: 1;
entries: Record<string, ExtensionRegistryEntry>;
}
// ─── Validation ─────────────────────────────────────────────────────────────
function isRegistry(data: unknown): data is ExtensionRegistry {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record<string, unknown>;
return (
obj.version === 1 && typeof obj.entries === "object" && obj.entries !== null
);
}
function isManifest(data: unknown): data is ExtensionManifest {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record<string, unknown>;
return (
typeof obj.id === "string" &&
typeof obj.name === "string" &&
typeof obj.version === "string" &&
typeof obj.tier === "string"
);
}
// ─── Registry Path ──────────────────────────────────────────────────────────
/**
* Returns the absolute path to the persisted registry JSON file.
*
* Purpose: centralise the registry location so every I/O operation targets the
* same file and the path can be overridden in tests.
*
* Consumer: loadRegistry(), saveRegistry(), and test fixtures that need to
* point at a temporary registry.
*/
export function getRegistryPath(): string {
return join(appRoot, "extensions", "registry.json");
}
// ─── Registry I/O ───────────────────────────────────────────────────────────
function defaultRegistry(): ExtensionRegistry {
return { version: 1, entries: {} };
}
/**
* Reads the registry from disk, returning a default empty registry if the file
* is missing, malformed, or unreadable.
*
* Purpose: guarantee that every caller receives a valid ExtensionRegistry object
* without having to handle I/O edge cases themselves.
*
* Consumer: ensureRegistryEntries(), the extension loader, and CLI commands that
* need to inspect or mutate extension state.
*/
export function loadRegistry(): ExtensionRegistry {
const filePath = getRegistryPath();
try {
if (!existsSync(filePath)) return defaultRegistry();
const raw = readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw);
return isRegistry(parsed) ? parsed : defaultRegistry();
} catch {
return defaultRegistry();
}
}
/**
* Atomically writes the registry to disk using a temp-file + rename pattern.
*
* Purpose: prevent corrupt or partial registry files if the process crashes
* mid-write, and silently swallow non-fatal persistence errors so the CLI
* remains usable even when the filesystem is read-only.
*
* Consumer: enableExtension(), disableExtension(), and ensureRegistryEntries().
*/
export function saveRegistry(registry: ExtensionRegistry): void {
const filePath = getRegistryPath();
try {
mkdirSync(dirname(filePath), { recursive: true });
const tmp = filePath + ".tmp";
writeFileSync(tmp, JSON.stringify(registry, null, 2), "utf-8");
renameSync(tmp, filePath);
} catch {
// Non-fatal — don't let persistence failures break operation
}
}
// ─── Query ──────────────────────────────────────────────────────────────────
/**
* Returns true if the extension is enabled (missing entries default to enabled).
*
* Purpose: let the loader decide whether to activate an extension without
* requiring every caller to know the "missing means enabled" default.
*
* Consumer: the extension loader and `sf extensions list` when rendering status.
*/
export function isExtensionEnabled(
registry: ExtensionRegistry,
id: string,
): boolean {
const entry = registry.entries[id];
if (!entry) return true;
return entry.enabled;
}
// ─── Mutations ──────────────────────────────────────────────────────────────
/**
* Marks an extension as enabled, clearing any previous disable metadata.
*
* Purpose: provide the atomic state transition used by `sf extensions enable`
* and by the auto-discovery flow when a new extension is first seen.
*
* Consumer: `sf extensions enable` command handler and ensureRegistryEntries().
*/
export function enableExtension(registry: ExtensionRegistry, id: string): void {
const entry = registry.entries[id];
if (entry) {
entry.enabled = true;
delete entry.disabledAt;
delete entry.disabledReason;
} else {
registry.entries[id] = { id, enabled: true, source: "bundled" };
}
}
/**
* Disable an extension. Returns an error string if the extension is core (cannot disable),
* or null on success.
*
* Purpose: protect core extensions from accidental disablement while allowing users
* to turn off bundled or community extensions and recording why.
*
* Consumer: `sf extensions disable` command handler.
*/
export function disableExtension(
registry: ExtensionRegistry,
id: string,
manifest: ExtensionManifest | null,
reason?: string,
): string | null {
if (manifest?.tier === "core") {
return `Cannot disable "${id}" — it is a core extension.`;
}
const entry = registry.entries[id];
if (entry) {
entry.enabled = false;
entry.disabledAt = new Date().toISOString();
entry.disabledReason = reason;
} else {
registry.entries[id] = {
id,
enabled: false,
source: "bundled",
disabledAt: new Date().toISOString(),
disabledReason: reason,
};
}
return null;
}
// ─── Manifest Reading ───────────────────────────────────────────────────────
/**
* Read extension-manifest.json from a directory. Returns null if missing or invalid.
*
* Purpose: isolate manifest parsing and validation so callers receive either a
* fully typed ExtensionManifest or a clear null signal.
*
* Consumer: discoverAllManifests(), readManifestFromEntryPath(), and tests that
* verify manifest schema evolution.
*/
export function readManifest(extensionDir: string): ExtensionManifest | null {
const manifestPath = join(extensionDir, "extension-manifest.json");
if (!existsSync(manifestPath)) return null;
try {
const raw = JSON.parse(readFileSync(manifestPath, "utf-8"));
return isManifest(raw) ? raw : null;
} catch {
return null;
}
}
/**
* Given an entry path (e.g. `.../extensions/browser-tools/index.ts`),
* resolve the parent directory and read its manifest.
*
* Purpose: bridge the gap between a discovered entry-point file and its
* containing extension's metadata, used when the loader needs tier or
* dependency information for a specific file.
*
* Consumer: extension loader when validating an entry point before require()ing it.
*/
export function readManifestFromEntryPath(
entryPath: string,
): ExtensionManifest | null {
const dir = dirname(entryPath);
return readManifest(dir);
}
// ─── Discovery ──────────────────────────────────────────────────────────────
/**
* Scan all subdirectories of extensionsDir for manifests. Returns a Map<id, manifest>.
*
* Purpose: produce a complete, de-duplicated inventory of installed extensions
* so the registry can be reconciled against the filesystem.
*
* Consumer: ensureRegistryEntries() and the extension loader's bootstrap phase.
*/
export function discoverAllManifests(
extensionsDir: string,
): Map<string, ExtensionManifest> {
const manifests = new Map<string, ExtensionManifest>();
if (!existsSync(extensionsDir)) return manifests;
for (const entry of readdirSync(extensionsDir, { withFileTypes: true })) {
// Accept both real directories and directory symlinks. Dirent.isDirectory()
// returns false for symlinks even when they point at a directory, so dev-workflow
// symlinked extensions under ~/.sf/agent/extensions/ are invisible otherwise
// (regression tested in symlink-extension-discovery.test.ts).
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
const manifest = readManifest(join(extensionsDir, entry.name));
if (manifest) {
manifests.set(manifest.id, manifest);
}
}
return manifests;
}
/**
* Auto-populate registry entries for newly discovered extensions.
* Extensions already in the registry are left untouched.
*
* Purpose: keep the registry in sync with the filesystem after installs or
* updates without overwriting user preferences (e.g. disabled state).
*
* Consumer: sf-run startup sequence and `sf extensions sync` command.
*/
export function ensureRegistryEntries(extensionsDir: string): void {
const manifests = discoverAllManifests(extensionsDir);
if (manifests.size === 0) return;
const registry = loadRegistry();
let changed = false;
for (const [id, _manifest] of manifests) {
if (!registry.entries[id]) {
registry.entries[id] = {
id,
enabled: true,
source: "bundled",
};
changed = true;
}
}
if (changed) {
saveRegistry(registry);
}
}