fix(copilot): make Node hook installation transactional

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 12:05:30 -04:00
1 parent cf77d49a3c
commit 2f988e72ba
14 files changed
+1722 -647

No files matched your search

+68 -49
View File
@@ -1,81 +1,100 @@
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
import {
AGENT_KEY,
AUDIT_SCRIPT,
GUARD_SCRIPT,
buildHandler,
removeManagedHooks,
renderDocument,
runtimeContracts,
type CopilotSources,
type RenderedDocument,
} from './copilot-contract.js';
import {
readSources,
requireRuntime,
runtimeFilesExist,
writeDocuments,
} from './copilot-io.js';
commitCopilotInstallation,
commitCopilotUninstall,
preflightCopilotInstallation,
prepareCopilotInstallation,
prepareCopilotUninstall,
} from './copilot-installation.js';
import { readSources, runtimeFilesExist } from './copilot-io.js';
import { SUPPORTED_AGENTS } from './registry.js';
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
function mergedContracts(
userHooks: Parameters<typeof runtimeContracts>[0],
legacyHooks?: Parameters<typeof runtimeContracts>[0],
): ReturnType<typeof runtimeContracts> {
const contracts = [
...runtimeContracts(userHooks),
...runtimeContracts(legacyHooks ?? {}),
function renderInstallation(
sources: CopilotSources,
guardPath: string,
auditPath: string,
): RenderedDocument[] {
const userHooks = removeManagedHooks(sources.user.hooks);
userHooks.preToolUse = [...(userHooks.preToolUse ?? []), buildHandler(guardPath)];
userHooks.postToolUse = [...(userHooks.postToolUse ?? []), buildHandler(auditPath)];
userHooks.postToolUseFailure = [
...(userHooks.postToolUseFailure ?? []),
buildHandler(auditPath),
];
const unique = new Map(contracts.map((contract) => [contract.agentId, contract]));
const rendered = [renderDocument(sources.user, userHooks)];
if (sources.legacy) {
rendered.push(renderDocument(
sources.legacy,
removeManagedHooks(sources.legacy.hooks),
));
}
return rendered;
}
function renderUninstall(sources: CopilotSources, agentId?: string): RenderedDocument[] {
const rendered = [
renderDocument(sources.user, removeManagedHooks(sources.user.hooks, agentId)),
];
if (sources.legacy) {
rendered.push(renderDocument(
sources.legacy,
removeManagedHooks(sources.legacy.hooks, agentId),
));
}
return rendered;
}
function mergedContracts(sources: CopilotSources) {
const contracts = [
...runtimeContracts(sources.user.hooks),
...runtimeContracts(sources.legacy?.hooks ?? {}),
];
const unique = new Map(contracts.map((contract) => [
process.platform === 'win32' ? contract.agentId.toLowerCase() : contract.agentId,
contract,
]));
return [...unique.values()];
}
export const copilotPlugin: AgentPlugin = {
async install(config: InstallConfig): Promise<void> {
if (!config.agentId) throw new Error('agentId is required');
managesRuntime: true,
async preflightInstall(config: InstallConfig): Promise<void> {
const sources = await readSources();
await requireRuntime(config.guardScriptPath, 'Elydora guard runtime');
await requireRuntime(config.hookScriptPath, 'Elydora audit runtime');
await preflightCopilotInstallation(config, sources);
},
const userHooks = removeManagedHooks(sources.user.hooks);
userHooks.preToolUse = [
...(userHooks.preToolUse ?? []),
buildHandler(config.guardScriptPath),
];
userHooks.postToolUse = [
...(userHooks.postToolUse ?? []),
buildHandler(config.hookScriptPath),
];
const rendered = [renderDocument(sources.user, userHooks)];
if (sources.legacy) {
rendered.push(renderDocument(
sources.legacy,
removeManagedHooks(sources.legacy.hooks),
));
}
await writeDocuments(rendered);
async install(config: InstallConfig): Promise<void> {
const sources = await readSources();
const paths = await preflightCopilotInstallation(config, sources);
const rendered = renderInstallation(sources, paths.guardPath, paths.auditPath);
const prepared = await prepareCopilotInstallation(config, sources, rendered);
await commitCopilotInstallation(prepared);
console.log(` GitHub Copilot CLI hooks: ${sources.user.filePath}`);
console.log(' GitHub Copilot CLI: restart active sessions to load the updated hooks.');
},
async uninstall(agentId?: string): Promise<void> {
const sources = await readSources();
const rendered = [
renderDocument(sources.user, removeManagedHooks(sources.user.hooks, agentId)),
];
if (sources.legacy) {
rendered.push(renderDocument(
sources.legacy,
removeManagedHooks(sources.legacy.hooks, agentId),
));
}
await writeDocuments(rendered);
const prepared = await prepareCopilotUninstall(renderUninstall(sources, agentId));
await commitCopilotUninstall(prepared);
},
async status(): Promise<PluginStatus> {
const sources = await readSources();
const contracts = mergedContracts(sources.user.hooks, sources.legacy?.hooks);
const hookConfigured = contracts.length > 0;
const contracts = mergedContracts(sources);
const hookConfigured = sources.disabledBy === undefined && contracts.length > 0;
const hookScriptExists = hookConfigured && await runtimeFilesExist(contracts);
return {
installed: hookConfigured && hookScriptExists,