mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-17 23:28:04 +02:00
Reject directory replacement after temporary file creation or staging, preserve unrelated files during cleanup, and retry settings edits observed before the final rename. Add three regression tests for the review findings.
694 lines
23 KiB
JavaScript
694 lines
23 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { isDeepStrictEqual } = require('util');
|
|
const { writeFileAtomic } = require('../atomic-write');
|
|
const { acquireSettingsLock, runWithSettingsLock } = require('./claude-settings-lock');
|
|
|
|
const CLAUDE_SETTINGS_FILENAME = 'settings.json';
|
|
const CLAUDE_HOOKS_CONFIG_PATH = 'hooks/hooks.json';
|
|
const PLUGIN_ROOT_PLACEHOLDER = '${CLAUDE_PLUGIN_ROOT}';
|
|
const PLUGIN_ROOT_ENV_PROLOGUE = 'var e=process.env.CLAUDE_PLUGIN_ROOT;';
|
|
const PLUGIN_ROOT_ENV_READ = /\bprocess\.env\.CLAUDE_PLUGIN_ROOT\b(?!\s*=)/;
|
|
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']);
|
|
|
|
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 getClaudeSettingsPath(targetRoot) {
|
|
return path.join(targetRoot, CLAUDE_SETTINGS_FILENAME);
|
|
}
|
|
|
|
function assertClaudeSettingsPath(destinationPath, trustedRoot) {
|
|
const resolvedDestination = path.resolve(destinationPath);
|
|
const resolvedExpected = path.resolve(getClaudeSettingsPath(trustedRoot));
|
|
const pathsMatch = process.platform === 'win32'
|
|
? resolvedDestination.toLowerCase() === resolvedExpected.toLowerCase()
|
|
: resolvedDestination === resolvedExpected;
|
|
if (!pathsMatch) {
|
|
throw new Error(
|
|
`Refusing to manage Claude hooks outside the canonical settings file: ${destinationPath}`
|
|
);
|
|
}
|
|
}
|
|
|
|
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 validateRecordedManagedHooks(managedHooks, label = 'recorded managed hooks') {
|
|
if (!isJsonObject(managedHooks) || Object.keys(managedHooks).length === 0) {
|
|
throw new Error(`Invalid ${label}: expected a non-empty JSON object`);
|
|
}
|
|
for (const [event, entries] of Object.entries(managedHooks)) {
|
|
if (!isNonEmptyString(event) || !Array.isArray(entries) || entries.length === 0) {
|
|
throw new Error(`Invalid ${label}.${event}: expected a non-empty hook array`);
|
|
}
|
|
const seenIds = new Set();
|
|
entries.forEach((entry, index) => {
|
|
if (!isJsonObject(entry) || !isNonEmptyString(entry.id) || !Array.isArray(entry.hooks)) {
|
|
throw new Error(`Invalid hook entry at ${label}.${event}[${index}]`);
|
|
}
|
|
if (seenIds.has(entry.id)) {
|
|
throw new Error(
|
|
`Invalid ${label}: expected unique id "${entry.id}" within event "${event}"`
|
|
);
|
|
}
|
|
seenIds.add(entry.id);
|
|
});
|
|
}
|
|
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')`;
|
|
const resolveCommand = command => {
|
|
const resolved = command
|
|
.split(PLUGIN_ROOT_ENV_PROLOGUE)
|
|
.join(`var e=${rootExpression};`);
|
|
if (PLUGIN_ROOT_ENV_READ.test(resolved)) {
|
|
throw new Error(
|
|
'Unable to resolve CLAUDE_PLUGIN_ROOT in a managed hook command; '
|
|
+ 'the hooks.json command prologue no longer matches the expected form'
|
|
);
|
|
}
|
|
return resolved;
|
|
};
|
|
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: resolveCommand(hook.command),
|
|
}
|
|
: {}),
|
|
})),
|
|
})),
|
|
])
|
|
);
|
|
}
|
|
|
|
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 updateSettingsAtomic(settingsPath, transform, options = {}) {
|
|
const update = () => {
|
|
const parentPath = path.dirname(path.resolve(settingsPath));
|
|
const parentStats = fs.lstatSync(parentPath, { bigint: true });
|
|
const validateParent = () => {
|
|
const current = fs.lstatSync(parentPath, { bigint: true });
|
|
if (
|
|
!current.isDirectory() || current.isSymbolicLink()
|
|
|| current.dev !== parentStats.dev || current.ino !== parentStats.ino
|
|
) {
|
|
const error = new Error(`Claude settings parent directory changed: ${parentPath}`);
|
|
error.code = 'ECC_SETTINGS_PARENT_CHANGED';
|
|
throw error;
|
|
}
|
|
};
|
|
const maxAttempts = options.maxAttempts || 3;
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
try {
|
|
validateParent();
|
|
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,
|
|
validateParent,
|
|
beforeRename() {
|
|
assertSettingsSnapshotUnchanged(settingsPath, snapshot);
|
|
},
|
|
}
|
|
);
|
|
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();
|
|
}
|
|
return runWithSettingsLock(settingsPath, update);
|
|
}
|
|
|
|
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
|
|
: validateRecordedManagedHooks(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 = validateRecordedManagedHooks(recordedManagedHooks);
|
|
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 = {
|
|
CLAUDE_HOOKS_CONFIG_PATH,
|
|
CLAUDE_SETTINGS_FILENAME,
|
|
acquireSettingsLock,
|
|
assertClaudeSettingsPath,
|
|
getClaudeSettingsPath,
|
|
inspectManagedHooks,
|
|
materializeManagedHooks,
|
|
mergeManagedHooks,
|
|
parseSettings,
|
|
readSettings,
|
|
repairManagedHooks,
|
|
replacePluginRootPlaceholders,
|
|
runWithSettingsLock,
|
|
updateSettingsAtomic,
|
|
uninstallManagedHooks,
|
|
validateManagedHooks,
|
|
validateRecordedManagedHooks,
|
|
validateSettings,
|
|
};
|