mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-18 07:37:59 +02:00
751 lines
24 KiB
JavaScript
751 lines
24 KiB
JavaScript
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { isDeepStrictEqual } = require('util');
|
|
const { writeFileAtomic } = require('../atomic-write');
|
|
|
|
const PLUGIN_ROOT_PLACEHOLDER = '${CLAUDE_PLUGIN_ROOT}';
|
|
const VALID_EVENTS = new Set([
|
|
'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest',
|
|
'PostToolUse', 'PostToolUseFailure', 'Notification', 'SubagentStart',
|
|
'Stop', 'SubagentStop', 'PreCompact', 'InstructionsLoaded',
|
|
'TeammateIdle', 'TaskCompleted', 'ConfigChange', 'WorktreeCreate',
|
|
'WorktreeRemove', 'SessionEnd',
|
|
]);
|
|
const EVENTS_WITHOUT_MATCHER = new Set([
|
|
'UserPromptSubmit', 'Notification', 'Stop', 'SubagentStop',
|
|
]);
|
|
const VALID_HOOK_TYPES = new Set(['command', 'http', 'prompt', 'agent']);
|
|
const INVALID_LOCK_STALE_MS = 5 * 60 * 1000;
|
|
|
|
function isJsonObject(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return false;
|
|
}
|
|
const prototype = Object.getPrototypeOf(value);
|
|
return prototype === Object.prototype || prototype === null;
|
|
}
|
|
|
|
function cloneValue(value) {
|
|
if (Array.isArray(value)) {
|
|
return value.map(cloneValue);
|
|
}
|
|
if (isJsonObject(value)) {
|
|
return Object.fromEntries(
|
|
Object.entries(value).map(([key, nestedValue]) => [key, cloneValue(nestedValue)])
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function isNonEmptyString(value) {
|
|
return typeof value === 'string' && value.trim() !== '';
|
|
}
|
|
|
|
function validateHookHandler(hook, label) {
|
|
if (!isJsonObject(hook)) {
|
|
throw new Error(`Invalid managed hook handler at ${label}: expected a JSON object`);
|
|
}
|
|
if (!VALID_HOOK_TYPES.has(hook.type)) {
|
|
throw new Error(`Invalid managed hook handler at ${label}: unsupported type`);
|
|
}
|
|
if (hook.timeout !== undefined && (typeof hook.timeout !== 'number' || hook.timeout < 0)) {
|
|
throw new Error(`Invalid managed hook handler at ${label}: invalid timeout`);
|
|
}
|
|
|
|
if (hook.type === 'command') {
|
|
const validCommand = isNonEmptyString(hook.command)
|
|
|| (Array.isArray(hook.command)
|
|
&& hook.command.length > 0
|
|
&& hook.command.every(isNonEmptyString));
|
|
if (!validCommand) {
|
|
throw new Error(`Invalid managed hook handler at ${label}: invalid command`);
|
|
}
|
|
if (hook.async !== undefined && typeof hook.async !== 'boolean') {
|
|
throw new Error(`Invalid managed hook handler at ${label}: invalid async flag`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (hook.async !== undefined) {
|
|
throw new Error(`Invalid managed hook handler at ${label}: async requires command type`);
|
|
}
|
|
if (hook.type === 'http') {
|
|
if (!isNonEmptyString(hook.url)) {
|
|
throw new Error(`Invalid managed hook handler at ${label}: invalid url`);
|
|
}
|
|
if (
|
|
hook.headers !== undefined
|
|
&& (!isJsonObject(hook.headers)
|
|
|| !Object.values(hook.headers).every(value => typeof value === 'string'))
|
|
) {
|
|
throw new Error(`Invalid managed hook handler at ${label}: invalid headers`);
|
|
}
|
|
if (
|
|
hook.allowedEnvVars !== undefined
|
|
&& (!Array.isArray(hook.allowedEnvVars)
|
|
|| !hook.allowedEnvVars.every(isNonEmptyString))
|
|
) {
|
|
throw new Error(`Invalid managed hook handler at ${label}: invalid allowedEnvVars`);
|
|
}
|
|
return;
|
|
}
|
|
if (!isNonEmptyString(hook.prompt)) {
|
|
throw new Error(`Invalid managed hook handler at ${label}: invalid prompt`);
|
|
}
|
|
if (hook.model !== undefined && !isNonEmptyString(hook.model)) {
|
|
throw new Error(`Invalid managed hook handler at ${label}: invalid model`);
|
|
}
|
|
}
|
|
|
|
function validateManagedHooks(managedHooks, label = 'managed hooks') {
|
|
if (!isJsonObject(managedHooks)) {
|
|
throw new Error(`Invalid ${label}: expected a JSON object`);
|
|
}
|
|
if (Object.keys(managedHooks).length === 0) {
|
|
throw new Error(`Invalid ${label}: expected at least one hook event`);
|
|
}
|
|
|
|
const seenIds = new Set();
|
|
for (const [event, entries] of Object.entries(managedHooks)) {
|
|
if (!VALID_EVENTS.has(event)) {
|
|
throw new Error(`Invalid ${label}: unsupported hook event "${event}"`);
|
|
}
|
|
if (!Array.isArray(entries)) {
|
|
throw new Error(`Invalid ${label}.${event}: expected an array`);
|
|
}
|
|
if (entries.length === 0) {
|
|
throw new Error(`Invalid ${label}.${event}: expected at least one hook entry`);
|
|
}
|
|
|
|
entries.forEach((entry, index) => {
|
|
if (!isJsonObject(entry)) {
|
|
throw new Error(
|
|
`Invalid managed hook entry at ${label}.${event}[${index}]: expected a JSON object`
|
|
);
|
|
}
|
|
if (typeof entry.id !== 'string' || entry.id.trim() === '') {
|
|
throw new Error(
|
|
`Invalid managed hook entry at ${label}.${event}[${index}]: `
|
|
+ 'expected a non-empty unique id'
|
|
);
|
|
}
|
|
if (seenIds.has(entry.id)) {
|
|
throw new Error(`Invalid ${label}: expected globally unique id "${entry.id}"`);
|
|
}
|
|
seenIds.add(entry.id);
|
|
if (
|
|
!Object.prototype.hasOwnProperty.call(entry, 'matcher')
|
|
&& !EVENTS_WITHOUT_MATCHER.has(event)
|
|
) {
|
|
throw new Error(
|
|
`Invalid managed hook entry at ${label}.${event}[${index}]: missing matcher`
|
|
);
|
|
}
|
|
if (
|
|
Object.prototype.hasOwnProperty.call(entry, 'matcher')
|
|
&& typeof entry.matcher !== 'string'
|
|
&& !isJsonObject(entry.matcher)
|
|
) {
|
|
throw new Error(
|
|
`Invalid managed hook entry at ${label}.${event}[${index}]: invalid matcher`
|
|
);
|
|
}
|
|
if (!Array.isArray(entry.hooks) || entry.hooks.length === 0) {
|
|
throw new Error(
|
|
`Invalid managed hook entry at ${label}.${event}[${index}]: expected hooks`
|
|
);
|
|
}
|
|
entry.hooks.forEach((hook, hookIndex) => {
|
|
validateHookHandler(hook, `${label}.${event}[${index}].hooks[${hookIndex}]`);
|
|
});
|
|
});
|
|
}
|
|
|
|
return cloneValue(managedHooks);
|
|
}
|
|
|
|
function validateSettings(settings, label = 'Claude settings') {
|
|
if (!isJsonObject(settings)) {
|
|
throw new Error(`Invalid ${label}: expected a JSON object`);
|
|
}
|
|
|
|
if (Object.prototype.hasOwnProperty.call(settings, 'hooks')) {
|
|
if (!isJsonObject(settings.hooks)) {
|
|
throw new Error(`Invalid ${label}: expected "hooks" to be a JSON object`);
|
|
}
|
|
for (const [event, entries] of Object.entries(settings.hooks)) {
|
|
if (!Array.isArray(entries)) {
|
|
throw new Error(`Invalid ${label}: expected hooks.${event} to be an array`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return cloneValue(settings);
|
|
}
|
|
|
|
function replacePluginRootPlaceholders(value, pluginRoot) {
|
|
if (typeof pluginRoot !== 'string') {
|
|
throw new Error('Invalid Claude plugin root: expected a string');
|
|
}
|
|
if (typeof value === 'string') {
|
|
return value.split(PLUGIN_ROOT_PLACEHOLDER).join(pluginRoot);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.map(item => replacePluginRootPlaceholders(item, pluginRoot));
|
|
}
|
|
if (isJsonObject(value)) {
|
|
return Object.fromEntries(
|
|
Object.entries(value).map(([key, nestedValue]) => [
|
|
key,
|
|
replacePluginRootPlaceholders(nestedValue, pluginRoot),
|
|
])
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function resolveManagedHookCommands(managedHooks, targetRoot) {
|
|
const encodedRoot = Buffer.from(targetRoot, 'utf8').toString('base64');
|
|
const rootExpression = `Buffer.from('${encodedRoot}','base64').toString('utf8')`;
|
|
return Object.fromEntries(
|
|
Object.entries(managedHooks).map(([event, entries]) => [
|
|
event,
|
|
entries.map(entry => ({
|
|
...entry,
|
|
hooks: entry.hooks.map(hook => ({
|
|
...hook,
|
|
...(typeof hook.command === 'string'
|
|
? {
|
|
command: hook.command
|
|
.split('var e=process.env.CLAUDE_PLUGIN_ROOT;')
|
|
.join(`var e=${rootExpression};`),
|
|
}
|
|
: {}),
|
|
})),
|
|
})),
|
|
])
|
|
);
|
|
}
|
|
|
|
function materializeManagedHooks(hooksConfig, targetRoot) {
|
|
if (!isJsonObject(hooksConfig) || !isJsonObject(hooksConfig.hooks)) {
|
|
throw new Error('Invalid hooks config: expected a JSON object with a hooks object');
|
|
}
|
|
if (!isNonEmptyString(targetRoot)) {
|
|
throw new Error('Invalid Claude target root: expected a non-empty string');
|
|
}
|
|
return validateManagedHooks(resolveManagedHookCommands(
|
|
replacePluginRootPlaceholders(hooksConfig.hooks, targetRoot),
|
|
targetRoot
|
|
));
|
|
}
|
|
|
|
function parseSettings(rawSettings, label = 'Claude settings') {
|
|
let settings;
|
|
try {
|
|
settings = JSON.parse(rawSettings);
|
|
} catch (error) {
|
|
throw new Error(`Failed to parse ${label}: ${error.message}`, { cause: error });
|
|
}
|
|
return validateSettings(settings, label);
|
|
}
|
|
|
|
function readSettings(settingsPath, fileSystem = fs) {
|
|
const reader = fileSystem && fileSystem.fs ? fileSystem.fs : fileSystem;
|
|
let rawSettings;
|
|
try {
|
|
rawSettings = reader.readFileSync(settingsPath, 'utf8');
|
|
} catch (error) {
|
|
if (error && error.code === 'ENOENT') {
|
|
return {};
|
|
}
|
|
throw error;
|
|
}
|
|
return parseSettings(rawSettings, `Claude settings at ${settingsPath}`);
|
|
}
|
|
|
|
function readSettingsSnapshot(settingsPath) {
|
|
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
|
|
let descriptor;
|
|
try {
|
|
descriptor = fs.openSync(settingsPath, flags);
|
|
} catch (error) {
|
|
if (error && error.code === 'ENOENT') {
|
|
return { exists: false, raw: null, settings: {}, mode: 0o600 };
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
const descriptorStat = fs.fstatSync(descriptor);
|
|
let pathStat;
|
|
try {
|
|
pathStat = fs.lstatSync(settingsPath);
|
|
} catch (error) {
|
|
if (error && error.code === 'ENOENT') {
|
|
error.code = 'ECC_SETTINGS_CHANGED';
|
|
}
|
|
throw error;
|
|
}
|
|
if (
|
|
!descriptorStat.isFile()
|
|
|| !pathStat.isFile()
|
|
|| pathStat.isSymbolicLink()
|
|
|| descriptorStat.dev !== pathStat.dev
|
|
|| descriptorStat.ino !== pathStat.ino
|
|
) {
|
|
const error = new Error(`Refusing to read changed Claude settings at ${settingsPath}`);
|
|
error.code = 'ECC_SETTINGS_CHANGED';
|
|
throw error;
|
|
}
|
|
const raw = fs.readFileSync(descriptor, 'utf8');
|
|
return {
|
|
exists: true,
|
|
raw,
|
|
settings: parseSettings(raw, `Claude settings at ${settingsPath}`),
|
|
mode: descriptorStat.mode & 0o777,
|
|
dev: descriptorStat.dev,
|
|
ino: descriptorStat.ino,
|
|
};
|
|
} finally {
|
|
fs.closeSync(descriptor);
|
|
}
|
|
}
|
|
|
|
function assertSettingsSnapshotUnchanged(settingsPath, snapshot) {
|
|
let current;
|
|
try {
|
|
current = readSettingsSnapshot(settingsPath);
|
|
} catch (error) {
|
|
error.code = error.code || 'ECC_SETTINGS_CHANGED';
|
|
throw error;
|
|
}
|
|
const unchanged = current.exists === snapshot.exists
|
|
&& current.raw === snapshot.raw
|
|
&& (!current.exists || (current.dev === snapshot.dev && current.ino === snapshot.ino));
|
|
if (!unchanged) {
|
|
const error = new Error(`Claude settings changed during update: ${settingsPath}`);
|
|
error.code = 'ECC_SETTINGS_CHANGED';
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function createSettingsLock(lockPath) {
|
|
const tempPath = `${lockPath}.create-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
|
|
let descriptor;
|
|
let ownedStats;
|
|
try {
|
|
descriptor = fs.openSync(tempPath, 'wx', 0o600);
|
|
fs.writeFileSync(descriptor, `${JSON.stringify({
|
|
pid: process.pid,
|
|
startedAt: new Date().toISOString(),
|
|
token: crypto.randomBytes(16).toString('hex'),
|
|
})}\n`);
|
|
fs.fsyncSync(descriptor);
|
|
ownedStats = fs.fstatSync(descriptor, { bigint: true });
|
|
fs.closeSync(descriptor);
|
|
descriptor = undefined;
|
|
fs.linkSync(tempPath, lockPath);
|
|
} catch (error) {
|
|
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
fs.rmSync(tempPath, { force: true });
|
|
throw error;
|
|
}
|
|
fs.rmSync(tempPath, { force: true });
|
|
|
|
let released = false;
|
|
return () => {
|
|
if (released) return;
|
|
released = true;
|
|
const quarantinePath = `${lockPath}.release-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
|
|
fs.renameSync(lockPath, quarantinePath);
|
|
const quarantinedStats = fs.lstatSync(quarantinePath, { bigint: true });
|
|
if (!sameFileIdentity(quarantinedStats, ownedStats)) {
|
|
if (!fs.existsSync(lockPath)) fs.renameSync(quarantinePath, lockPath);
|
|
throw new Error(`Refusing to release a changed Claude settings lock: ${lockPath}`);
|
|
}
|
|
fs.rmSync(quarantinePath, { force: true });
|
|
};
|
|
}
|
|
|
|
function sameFileIdentity(left, right) {
|
|
return left.dev === right.dev && left.ino === right.ino;
|
|
}
|
|
|
|
function inspectSettingsLock(lockPath) {
|
|
const descriptor = fs.openSync(lockPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
try {
|
|
const stats = fs.fstatSync(descriptor, { bigint: true });
|
|
const pathStats = fs.lstatSync(lockPath, { bigint: true });
|
|
if (
|
|
!stats.isFile()
|
|
|| pathStats.isSymbolicLink()
|
|
|| !pathStats.isFile()
|
|
|| !sameFileIdentity(stats, pathStats)
|
|
) {
|
|
return { metadata: null, stats };
|
|
}
|
|
let metadata = null;
|
|
try {
|
|
metadata = JSON.parse(fs.readFileSync(descriptor, 'utf8'));
|
|
} catch (_error) {
|
|
// Invalid locks may be recovered only after the bounded lease below.
|
|
}
|
|
return { metadata, stats };
|
|
} finally {
|
|
fs.closeSync(descriptor);
|
|
}
|
|
}
|
|
|
|
function processIsAlive(pid) {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
return error.code !== 'ESRCH';
|
|
}
|
|
}
|
|
|
|
function recoverSettingsLock(lockPath) {
|
|
const recoveryPath = `${lockPath}.recover`;
|
|
try {
|
|
fs.mkdirSync(recoveryPath, { mode: 0o700 });
|
|
} catch (error) {
|
|
if (error && error.code === 'EEXIST') return null;
|
|
throw error;
|
|
}
|
|
|
|
const quarantinePath = `${lockPath}.stale-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
|
|
try {
|
|
let inspected;
|
|
try {
|
|
inspected = inspectSettingsLock(lockPath);
|
|
} catch (error) {
|
|
if (error && error.code === 'ENOENT') return createSettingsLock(lockPath);
|
|
throw error;
|
|
}
|
|
const validOwner = Number.isSafeInteger(inspected.metadata && inspected.metadata.pid)
|
|
&& inspected.metadata.pid > 0;
|
|
const stale = validOwner
|
|
? !processIsAlive(inspected.metadata.pid)
|
|
: Date.now() - Number(inspected.stats.mtimeMs) >= INVALID_LOCK_STALE_MS;
|
|
if (!stale) return null;
|
|
|
|
fs.renameSync(lockPath, quarantinePath);
|
|
const quarantinedStats = fs.lstatSync(quarantinePath, { bigint: true });
|
|
if (!sameFileIdentity(quarantinedStats, inspected.stats)) {
|
|
if (!fs.existsSync(lockPath)) fs.renameSync(quarantinePath, lockPath);
|
|
return null;
|
|
}
|
|
fs.rmSync(quarantinePath, { force: true });
|
|
return createSettingsLock(lockPath);
|
|
} finally {
|
|
fs.rmSync(recoveryPath, { recursive: true, force: true });
|
|
fs.rmSync(quarantinePath, { force: true });
|
|
}
|
|
}
|
|
|
|
function acquireSettingsLock(settingsPath) {
|
|
const lockPath = `${settingsPath}.ecc.lock`;
|
|
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
try {
|
|
return createSettingsLock(lockPath);
|
|
} catch (error) {
|
|
if (!error || error.code !== 'EEXIST') {
|
|
throw error;
|
|
}
|
|
}
|
|
const recovered = recoverSettingsLock(lockPath);
|
|
if (recovered) return recovered;
|
|
throw new Error(
|
|
`Another ECC process is updating Claude settings: ${settingsPath}. `
|
|
+ `If no ECC process is active, inspect and remove ${lockPath}.`
|
|
);
|
|
}
|
|
|
|
function updateSettingsAtomic(settingsPath, transform, options = {}) {
|
|
const update = () => {
|
|
const maxAttempts = options.maxAttempts || 3;
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
try {
|
|
const snapshot = readSettingsSnapshot(settingsPath);
|
|
const result = transform(snapshot.settings);
|
|
if (typeof options.beforeCommit === 'function') options.beforeCommit();
|
|
assertSettingsSnapshotUnchanged(settingsPath, snapshot);
|
|
writeFileAtomic(
|
|
settingsPath,
|
|
`${JSON.stringify(result.settings, null, 2)}\n`,
|
|
{ encoding: 'utf8', mode: snapshot.mode }
|
|
);
|
|
return result;
|
|
} catch (error) {
|
|
if (error.code !== 'ECC_SETTINGS_CHANGED' || attempt === maxAttempts) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
throw new Error(`Unable to update Claude settings at ${settingsPath}`);
|
|
};
|
|
if (options.lockHeld) {
|
|
return update();
|
|
}
|
|
const releaseLock = acquireSettingsLock(settingsPath);
|
|
try {
|
|
return update();
|
|
} finally {
|
|
releaseLock();
|
|
}
|
|
}
|
|
|
|
function reference(event, id) {
|
|
return { event, id };
|
|
}
|
|
|
|
function entriesMatchingId(entries, id) {
|
|
return entries
|
|
.map((entry, index) => ({ entry, index }))
|
|
.filter(candidate => isJsonObject(candidate.entry) && candidate.entry.id === id);
|
|
}
|
|
|
|
function assertUnambiguousMatch(entries, event, id) {
|
|
const matches = entriesMatchingId(entries, id);
|
|
if (matches.length > 1) {
|
|
throw new Error(
|
|
`Claude settings contains multiple hooks for event "${event}" and id "${id}"`
|
|
);
|
|
}
|
|
return matches[0] || null;
|
|
}
|
|
|
|
function managedEntryFor(managedHooks, event, id) {
|
|
const entries = managedHooks && managedHooks[event];
|
|
if (!Array.isArray(entries)) {
|
|
return null;
|
|
}
|
|
return entries.find(entry => entry.id === id) || null;
|
|
}
|
|
|
|
function mergeManagedHooks(settings, managedHooks, options = {}) {
|
|
const validatedSettings = validateSettings(settings);
|
|
const desiredHooks = validateManagedHooks(managedHooks);
|
|
const previousHooks = options.previousManagedHooks === undefined
|
|
|| options.previousManagedHooks === null
|
|
? null
|
|
: validateManagedHooks(options.previousManagedHooks, 'previous managed hooks');
|
|
const repair = options.mode === 'repair' || options.repair === true;
|
|
if (options.mode !== undefined && options.mode !== 'merge' && options.mode !== 'repair') {
|
|
throw new Error(`Unknown Claude settings merge mode: ${options.mode}`);
|
|
}
|
|
|
|
let nextHooks = validatedSettings.hooks
|
|
? cloneValue(validatedSettings.hooks)
|
|
: {};
|
|
const added = [];
|
|
const updated = [];
|
|
const unchanged = [];
|
|
const removed = [];
|
|
|
|
if (previousHooks) {
|
|
for (const [event, previousEntries] of Object.entries(previousHooks)) {
|
|
let eventEntries = nextHooks[event] ? cloneValue(nextHooks[event]) : [];
|
|
for (const previousEntry of previousEntries) {
|
|
if (managedEntryFor(desiredHooks, event, previousEntry.id)) continue;
|
|
const match = assertUnambiguousMatch(eventEntries, event, previousEntry.id);
|
|
if (!match) continue;
|
|
if (!isDeepStrictEqual(match.entry, previousEntry)) {
|
|
throw new Error(
|
|
`Refusing to remove Claude hook for event "${event}" and id `
|
|
+ `"${previousEntry.id}" because the previous managed entry has drifted`
|
|
);
|
|
}
|
|
eventEntries = eventEntries.filter((_entry, index) => index !== match.index);
|
|
removed.push(reference(event, previousEntry.id));
|
|
}
|
|
nextHooks = eventEntries.length > 0
|
|
? { ...nextHooks, [event]: eventEntries }
|
|
: withoutProperty(nextHooks, event);
|
|
}
|
|
}
|
|
|
|
for (const [event, desiredEntries] of Object.entries(desiredHooks)) {
|
|
let eventEntries = nextHooks[event] ? cloneValue(nextHooks[event]) : [];
|
|
for (const desiredEntry of desiredEntries) {
|
|
const match = assertUnambiguousMatch(eventEntries, event, desiredEntry.id);
|
|
if (!match) {
|
|
eventEntries = [...eventEntries, cloneValue(desiredEntry)];
|
|
added.push(reference(event, desiredEntry.id));
|
|
continue;
|
|
}
|
|
if (isDeepStrictEqual(match.entry, desiredEntry)) {
|
|
unchanged.push(reference(event, desiredEntry.id));
|
|
continue;
|
|
}
|
|
|
|
const previousEntry = managedEntryFor(previousHooks, event, desiredEntry.id);
|
|
if (!repair && (!previousEntry || !isDeepStrictEqual(match.entry, previousEntry))) {
|
|
const driftReason = previousEntry ? ' because the previous managed entry has drifted' : '';
|
|
throw new Error(
|
|
`Refusing to overwrite Claude hook for event "${event}" and id `
|
|
+ `"${desiredEntry.id}"${driftReason}`
|
|
);
|
|
}
|
|
|
|
eventEntries = eventEntries.map((entry, index) => (
|
|
index === match.index ? cloneValue(desiredEntry) : entry
|
|
));
|
|
updated.push(reference(event, desiredEntry.id));
|
|
}
|
|
if (desiredEntries.length > 0) {
|
|
nextHooks = { ...nextHooks, [event]: eventEntries };
|
|
}
|
|
}
|
|
|
|
const nextSettings = Object.keys(nextHooks).length > 0
|
|
? { ...validatedSettings, hooks: nextHooks }
|
|
: validatedSettings;
|
|
return {
|
|
settings: nextSettings,
|
|
managedHooks: cloneValue(desiredHooks),
|
|
added,
|
|
updated,
|
|
unchanged,
|
|
removed,
|
|
};
|
|
}
|
|
|
|
function repairManagedHooks(settings, managedHooks, options = {}) {
|
|
return mergeManagedHooks(settings, managedHooks, {
|
|
...options,
|
|
mode: 'repair',
|
|
});
|
|
}
|
|
|
|
function inspectManagedHooks(settings, managedHooks) {
|
|
const validatedSettings = validateSettings(settings);
|
|
const expectedHooks = validateManagedHooks(managedHooks);
|
|
const settingsHooks = validatedSettings.hooks || {};
|
|
const managedSubset = {};
|
|
const matched = [];
|
|
const missing = [];
|
|
const drifted = [];
|
|
|
|
for (const [event, expectedEntries] of Object.entries(expectedHooks)) {
|
|
const actualEntries = settingsHooks[event] || [];
|
|
const foundEntries = [];
|
|
for (const expectedEntry of expectedEntries) {
|
|
const match = assertUnambiguousMatch(actualEntries, event, expectedEntry.id);
|
|
if (!match) {
|
|
missing.push(reference(event, expectedEntry.id));
|
|
continue;
|
|
}
|
|
|
|
foundEntries.push(cloneValue(match.entry));
|
|
if (isDeepStrictEqual(match.entry, expectedEntry)) {
|
|
matched.push(reference(event, expectedEntry.id));
|
|
} else {
|
|
drifted.push({
|
|
...reference(event, expectedEntry.id),
|
|
expected: cloneValue(expectedEntry),
|
|
actual: cloneValue(match.entry),
|
|
});
|
|
}
|
|
}
|
|
if (foundEntries.length > 0) {
|
|
managedSubset[event] = foundEntries;
|
|
}
|
|
}
|
|
|
|
const ok = missing.length === 0 && drifted.length === 0;
|
|
return {
|
|
status: ok ? 'ok' : (missing.length > 0 ? 'missing' : 'drifted'),
|
|
ok,
|
|
managedHooks: managedSubset,
|
|
matched,
|
|
missing,
|
|
drifted,
|
|
};
|
|
}
|
|
|
|
function withoutProperty(object, omittedKey) {
|
|
return Object.fromEntries(
|
|
Object.entries(object).filter(([key]) => key !== omittedKey)
|
|
);
|
|
}
|
|
|
|
function uninstallManagedHooks(settings, recordedManagedHooks) {
|
|
const validatedSettings = validateSettings(settings);
|
|
const recordedHooks = validateManagedHooks(recordedManagedHooks, 'recorded managed hooks');
|
|
const currentHooks = validatedSettings.hooks || {};
|
|
|
|
for (const [event, recordedEntries] of Object.entries(recordedHooks)) {
|
|
const eventEntries = currentHooks[event] || [];
|
|
for (const recordedEntry of recordedEntries) {
|
|
assertUnambiguousMatch(eventEntries, event, recordedEntry.id);
|
|
}
|
|
}
|
|
|
|
const removed = [];
|
|
const retained = [];
|
|
const missing = [];
|
|
let nextHooks = cloneValue(currentHooks);
|
|
|
|
for (const [event, recordedEntries] of Object.entries(recordedHooks)) {
|
|
let eventEntries = nextHooks[event] || [];
|
|
for (const recordedEntry of recordedEntries) {
|
|
const match = assertUnambiguousMatch(eventEntries, event, recordedEntry.id);
|
|
if (!match) {
|
|
missing.push(reference(event, recordedEntry.id));
|
|
continue;
|
|
}
|
|
if (!isDeepStrictEqual(match.entry, recordedEntry)) {
|
|
retained.push({
|
|
...reference(event, recordedEntry.id),
|
|
expected: cloneValue(recordedEntry),
|
|
actual: cloneValue(match.entry),
|
|
reason: 'modified',
|
|
});
|
|
continue;
|
|
}
|
|
|
|
eventEntries = eventEntries.filter((_entry, index) => index !== match.index);
|
|
removed.push(reference(event, recordedEntry.id));
|
|
}
|
|
nextHooks = eventEntries.length > 0
|
|
? { ...nextHooks, [event]: eventEntries }
|
|
: withoutProperty(nextHooks, event);
|
|
}
|
|
|
|
nextHooks = Object.fromEntries(
|
|
Object.entries(nextHooks).filter(([, entries]) => entries.length > 0)
|
|
);
|
|
const settingsWithoutHooks = withoutProperty(validatedSettings, 'hooks');
|
|
const nextSettings = Object.keys(nextHooks).length > 0
|
|
? { ...settingsWithoutHooks, hooks: nextHooks }
|
|
: settingsWithoutHooks;
|
|
|
|
return {
|
|
settings: nextSettings,
|
|
removed,
|
|
retained,
|
|
missing,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
acquireSettingsLock,
|
|
inspectManagedHooks,
|
|
materializeManagedHooks,
|
|
mergeManagedHooks,
|
|
parseSettings,
|
|
readSettings,
|
|
repairManagedHooks,
|
|
replacePluginRootPlaceholders,
|
|
updateSettingsAtomic,
|
|
uninstallManagedHooks,
|
|
validateManagedHooks,
|
|
validateSettings,
|
|
};
|