singularity-forge/packages/pi-coding-agent/src/core/lifecycle-hooks.ts
Jay The Reaper 68902466ac fix(core): address PR review feedback for non-apikey provider support (#2452)
- Strip apiKey from options at streamSimple registration boundary for
  externalCli/none providers — enforced structurally, not by convention
- Add registration-time validation: externalCli/none requires streamSimple,
  rejects contradictory apiKey, improved error messages mentioning authMode
- Cache legacy hook module imports to prevent side-effect double-execution
- Add isReady() trust boundary documentation
- Add inline comments on compaction-orchestrator apiKey flow
- Refactor package-commands.test.ts to use t.after() cleanup
- Add lifecycle-hooks.test.ts with 24 unit tests for readManifestRuntimeDeps,
  collectRuntimeDependencies, verifyRuntimeDependencies, resolveLocalSourcePath
- Expand model-registry-auth-mode.test.ts with streamSimple apiKey boundary
  tests and registration validation tests (80 total tests across all files)
- Add afterRemove deleted-directory edge case test
- Fix help-text.ts wording: "lifecycle hooks" → "post-install validation"
- Fix event.message null check documentation (intentional tightening)
2026-03-25 08:45:20 -06:00

280 lines
8.1 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { parseGitUrl } from "../utils/git.js";
import {
importExtensionModule,
loadExtensions,
type LifecycleHookContext,
type LifecycleHookMap,
type LifecycleHookHandler,
type LifecycleHookPhase,
type LifecycleHookScope,
} from "./extensions/index.js";
import type { DefaultPackageManager } from "./package-manager.js";
interface ExtensionManifest {
dependencies?: {
runtime?: string[];
};
}
export interface PackageLifecycleHooksOptions {
source: string;
local: boolean;
cwd: string;
agentDir: string;
appName: string;
packageManager: DefaultPackageManager;
stdout: NodeJS.WriteStream;
stderr: NodeJS.WriteStream;
}
export type LifecycleHooksTarget = "source" | "installed";
export interface PrepareLifecycleHooksOptions {
verifyRuntimeDependencies?: boolean;
}
export interface LifecycleHooksRunResult {
phase: LifecycleHookPhase;
hooksRun: number;
hookErrors: number;
legacyHooksRun: number;
entryPathCount: number;
skipped: boolean;
}
interface LoadedLifecycleHooks {
source: string;
scope: LifecycleHookScope;
installedPath?: string;
cwd: string;
stdout: NodeJS.WriteStream;
stderr: NodeJS.WriteStream;
entryPaths: string[];
hooksByPath: Map<string, LifecycleHookMap>;
}
function toScope(local: boolean): LifecycleHookScope {
return local ? "project" : "user";
}
export function readManifestRuntimeDeps(dir: string): string[] {
const manifestPath = join(dir, "extension-manifest.json");
if (!existsSync(manifestPath)) return [];
try {
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as ExtensionManifest;
return manifest.dependencies?.runtime?.filter((dep): dep is string => typeof dep === "string") ?? [];
} catch {
return [];
}
}
export function collectRuntimeDependencies(installedPath: string, entryPaths: string[]): string[] {
const deps = new Set<string>();
const candidateDirs = new Set<string>([installedPath, ...entryPaths.map((entryPath) => dirname(entryPath))]);
for (const dir of candidateDirs) {
for (const dep of readManifestRuntimeDeps(dir)) {
deps.add(dep);
}
}
return Array.from(deps);
}
export function verifyRuntimeDependencies(runtimeDeps: string[], source: string, appName: string): void {
const missing: string[] = [];
for (const dep of runtimeDeps) {
const result = spawnSync(dep, ["--version"], { encoding: "utf-8", timeout: 5000 });
if (result.error || result.status !== 0) {
missing.push(dep);
}
}
if (missing.length === 0) return;
throw new Error(
`Missing runtime dependencies: ${missing.join(", ")}.\n` +
`Install them and retry: ${appName} install ${source}`,
);
}
export function resolveLocalSourcePath(source: string, cwd: string): string | undefined {
const trimmed = source.trim();
if (!trimmed) return undefined;
if (trimmed.startsWith("npm:")) return undefined;
if (parseGitUrl(trimmed)) return undefined;
let normalized = trimmed;
if (normalized === "~") {
normalized = homedir();
} else if (normalized.startsWith("~/")) {
normalized = join(homedir(), normalized.slice(2));
}
const absolutePath = resolve(cwd, normalized);
return existsSync(absolutePath) ? absolutePath : undefined;
}
async function resolveEntryPathsFromTarget(
options: PackageLifecycleHooksOptions,
target: LifecycleHooksTarget,
scope: LifecycleHookScope,
): Promise<{ entryPaths: string[]; installedPath?: string }> {
if (target === "source") {
const localSourcePath = resolveLocalSourcePath(options.source, options.cwd);
if (!localSourcePath) return { entryPaths: [] };
const resolved = await options.packageManager.resolveExtensionSources([localSourcePath], { local: true });
const entryPaths = resolved.extensions.filter((resource) => resource.enabled).map((resource) => resource.path);
return { entryPaths, installedPath: localSourcePath };
}
const installedPath = options.packageManager.getInstalledPath(options.source, scope);
if (!installedPath) return { entryPaths: [] };
const resolved = await options.packageManager.resolveExtensionSources([installedPath], { local: true });
const entryPaths = resolved.extensions.filter((resource) => resource.enabled).map((resource) => resource.path);
return { entryPaths, installedPath };
}
export async function prepareLifecycleHooks(
options: PackageLifecycleHooksOptions,
target: LifecycleHooksTarget,
prepareOptions?: PrepareLifecycleHooksOptions,
): Promise<LoadedLifecycleHooks | null> {
const scope = toScope(options.local);
const { entryPaths, installedPath } = await resolveEntryPathsFromTarget(options, target, scope);
if (entryPaths.length === 0) {
return null;
}
if (prepareOptions?.verifyRuntimeDependencies && installedPath) {
const runtimeDeps = collectRuntimeDependencies(installedPath, entryPaths);
verifyRuntimeDependencies(runtimeDeps, options.source, options.appName);
}
const loaded = await loadExtensions(entryPaths, options.cwd);
for (const { path, error } of loaded.errors) {
options.stderr.write(`[lifecycle-hooks] Failed to load extension "${path}": ${error}\n`);
}
const hooksByPath = new Map<string, LifecycleHookMap>();
for (const extension of loaded.extensions) {
hooksByPath.set(extension.path, extension.lifecycleHooks);
}
return {
source: options.source,
scope,
installedPath,
cwd: options.cwd,
stdout: options.stdout,
stderr: options.stderr,
entryPaths,
hooksByPath,
};
}
async function runHookSafe(
hook: LifecycleHookHandler,
context: LifecycleHookContext,
stderr: NodeJS.WriteStream,
): Promise<boolean> {
try {
await hook(context);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
stderr.write(`[lifecycle-hooks:${context.phase}] Hook failed: ${message}\n`);
return false;
}
}
function getLegacyExportCandidates(phase: LifecycleHookPhase): string[] {
return [phase];
}
const _legacyModuleCache = new Map<string, Record<string, unknown>>();
async function runLegacyExportHook(
entryPath: string,
phase: LifecycleHookPhase,
context: LifecycleHookContext,
): Promise<LifecycleHookHandler | null> {
try {
let module = _legacyModuleCache.get(entryPath);
if (!module) {
module = await importExtensionModule<Record<string, unknown>>(import.meta.url, pathToFileURL(entryPath).href);
_legacyModuleCache.set(entryPath, module);
}
for (const exportName of getLegacyExportCandidates(phase)) {
const candidate = module[exportName];
if (typeof candidate === "function") {
return candidate as LifecycleHookHandler;
}
}
return null;
} catch {
return null;
}
}
export async function runLifecycleHooks(
loaded: LoadedLifecycleHooks | null,
phase: LifecycleHookPhase,
): Promise<LifecycleHooksRunResult> {
if (!loaded) {
return {
phase,
hooksRun: 0,
hookErrors: 0,
legacyHooksRun: 0,
entryPathCount: 0,
skipped: true,
};
}
const context: LifecycleHookContext = {
phase,
source: loaded.source,
installedPath: loaded.installedPath,
scope: loaded.scope,
cwd: loaded.cwd,
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
log: (message) => loaded.stdout.write(`${message}\n`),
warn: (message) => loaded.stderr.write(`${message}\n`),
error: (message) => loaded.stderr.write(`${message}\n`),
};
let hooksRun = 0;
let hookErrors = 0;
let legacyHooksRun = 0;
for (const entryPath of loaded.entryPaths) {
const hookMap = loaded.hooksByPath.get(entryPath);
const registeredHooks = hookMap?.[phase] ?? [];
if (registeredHooks.length > 0) {
for (const hook of registeredHooks) {
hooksRun += 1;
const ok = await runHookSafe(hook, context, loaded.stderr);
if (!ok) hookErrors += 1;
}
continue;
}
const legacyHook = await runLegacyExportHook(entryPath, phase, context);
if (!legacyHook) continue;
legacyHooksRun += 1;
const ok = await runHookSafe(legacyHook, context, loaded.stderr);
if (!ok) hookErrors += 1;
}
return {
phase,
hooksRun,
hookErrors,
legacyHooksRun,
entryPathCount: loaded.entryPaths.length,
skipped: false,
};
}