mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-17 23:28:04 +02:00
Merge reviewed Claude hook registration and settings containment fixes
This commit is contained in:
@@ -13,21 +13,34 @@ function writeFileAtomic(filePath, content, options = {}) {
|
||||
);
|
||||
const mode = options.mode || 0o600;
|
||||
|
||||
if (options.validateParent) options.validateParent();
|
||||
fs.mkdirSync(parentDir, { recursive: true });
|
||||
|
||||
let descriptor;
|
||||
try {
|
||||
if (options.validateParent) options.validateParent();
|
||||
descriptor = fs.openSync(tempPath, 'wx', mode);
|
||||
if (options.validateParent) options.validateParent();
|
||||
fs.writeFileSync(descriptor, content, { encoding: options.encoding || 'utf8' });
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
if (options.validateParent) options.validateParent();
|
||||
if (options.beforeRename) options.beforeRename();
|
||||
fs.renameSync(tempPath, resolvedPath);
|
||||
} catch (error) {
|
||||
if (descriptor !== undefined) {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
fs.rmSync(tempPath, { force: true });
|
||||
// If the parent was replaced, this pathname may now name somebody else's
|
||||
// file. Leave the private staging file in its original directory.
|
||||
let parentUnchanged = true;
|
||||
try {
|
||||
if (options.validateParent) options.validateParent();
|
||||
} catch (_error) {
|
||||
parentUnchanged = false;
|
||||
}
|
||||
if (parentUnchanged) fs.rmSync(tempPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ const fs = require('fs');
|
||||
const { execFileSync } = require('child_process');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { isDeepStrictEqual } = require('util');
|
||||
|
||||
const { loadInstallManifests } = require('./install-manifests');
|
||||
const { readInstallState, validateInstallState } = require('./install-state');
|
||||
@@ -20,6 +21,17 @@ const {
|
||||
getLegacyOpencodeLocation,
|
||||
inspectLegacyOpencodeState,
|
||||
} = require('./install/opencode-legacy-migration');
|
||||
const {
|
||||
acquireSettingsLock,
|
||||
assertClaudeSettingsPath,
|
||||
getClaudeSettingsPath,
|
||||
inspectManagedHooks,
|
||||
materializeManagedHooks,
|
||||
repairManagedHooks,
|
||||
uninstallManagedHooks,
|
||||
updateSettingsAtomic,
|
||||
validateManagedHooks,
|
||||
} = require('./install/claude-settings');
|
||||
const { adaptAntigravityAgent } = require('./install/antigravity-agent');
|
||||
const { buildInstallIndex, rewriteRelativeLinks } = require('./install/link-rewrite');
|
||||
const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry');
|
||||
@@ -523,6 +535,13 @@ function readJsonNoFollow(filePath) {
|
||||
return JSON.parse(readFileNoFollow(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function assertClaudeSettingsDestination(operation, trustedRoot, target = null) {
|
||||
if (target && target !== 'claude' && target !== 'claude-project') {
|
||||
throw new Error('Refusing to manage Claude hooks for a non-Claude target.');
|
||||
}
|
||||
assertClaudeSettingsPath(operation.destinationPath, trustedRoot);
|
||||
}
|
||||
|
||||
function writeContainedFile(destinationPath, content, trustedRoot, action, mode) {
|
||||
const preparedDestination = prepareContainedWriteDestination(destinationPath, trustedRoot, action);
|
||||
const finalDestination = getManagedDestination(
|
||||
@@ -687,8 +706,26 @@ function deepRemoveJsonSubset(currentValue, managedValue) {
|
||||
return currentValue === managedValue ? JSON_REMOVE_SENTINEL : currentValue;
|
||||
}
|
||||
|
||||
function hydrateRecordedOperations(repoRoot, operations) {
|
||||
function hydrateRecordedOperations(repoRoot, operations, trustedRoot) {
|
||||
return operations.map(operation => {
|
||||
if (operation.kind === 'update-claude-settings') {
|
||||
const sourcePath = resolveOperationSourcePath(repoRoot, operation);
|
||||
if (!sourcePath || !fs.existsSync(sourcePath)) {
|
||||
throw new Error(
|
||||
`Missing source file for repair: ${sourcePath || operation.sourceRelativePath}`
|
||||
);
|
||||
}
|
||||
return {
|
||||
...operation,
|
||||
sourcePath,
|
||||
previousManagedHooks: operation.managedHooks,
|
||||
managedHooks: materializeManagedHooks(
|
||||
readJsonNoFollow(sourcePath),
|
||||
trustedRoot
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (operation.kind !== 'copy-file') {
|
||||
return { ...operation };
|
||||
}
|
||||
@@ -717,7 +754,14 @@ function shouldRepairFromRecordedOperations(state) {
|
||||
return getManagedOperations(state).some(operation => operation.kind !== 'copy-file');
|
||||
}
|
||||
|
||||
function executeRepairOperation(repoRoot, operation, trustedRoot, linkIndex = null) {
|
||||
function executeRepairOperation(
|
||||
repoRoot,
|
||||
operation,
|
||||
trustedRoot,
|
||||
linkIndex = null,
|
||||
target = null,
|
||||
settingsLockHeld = false
|
||||
) {
|
||||
// Install-state is attacker-controllable; never write/delete outside the
|
||||
// adapter-derived trusted root, regardless of what the state file claims
|
||||
// (GHSA-hfpv-w6mp-5g95).
|
||||
@@ -770,6 +814,35 @@ function executeRepairOperation(repoRoot, operation, trustedRoot, linkIndex = nu
|
||||
return operation.destinationPath;
|
||||
}
|
||||
|
||||
if (operation.kind === 'update-claude-settings') {
|
||||
assertClaudeSettingsDestination(operation, trustedRoot, target);
|
||||
const managedHooks = validateManagedHooks(operation.managedHooks);
|
||||
const previousManagedHooks = operation.previousManagedHooks
|
||||
? validateManagedHooks(operation.previousManagedHooks, 'previous managed hooks')
|
||||
: null;
|
||||
const existingDestination = getContainedExistingPath(
|
||||
operation.destinationPath,
|
||||
trustedRoot,
|
||||
'repair'
|
||||
);
|
||||
const settingsPath = existingDestination
|
||||
? getManagedDestination(existingDestination, trustedRoot, 'repair').managedPath
|
||||
: prepareContainedWriteDestination(operation.destinationPath, trustedRoot, 'repair');
|
||||
updateSettingsAtomic(
|
||||
settingsPath,
|
||||
currentSettings => repairManagedHooks(currentSettings, managedHooks, {
|
||||
previousManagedHooks,
|
||||
}),
|
||||
{
|
||||
lockHeld: settingsLockHeld,
|
||||
beforeCommit() {
|
||||
getManagedDestination(settingsPath, trustedRoot, 'repair');
|
||||
},
|
||||
}
|
||||
);
|
||||
return operation.destinationPath;
|
||||
}
|
||||
|
||||
if (operation.kind === 'remove') {
|
||||
const removedPath = removeContainedPath(
|
||||
operation.destinationPath,
|
||||
@@ -938,6 +1011,45 @@ function executeUninstallOperation(operation, trustedRoot, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
if (operation.kind === 'update-claude-settings') {
|
||||
assertClaudeSettingsDestination(operation, trustedRoot, options.target);
|
||||
const existingDestination = getContainedExistingPath(
|
||||
operation.destinationPath,
|
||||
trustedRoot,
|
||||
'uninstall'
|
||||
);
|
||||
if (!existingDestination) {
|
||||
return {
|
||||
removedPaths: [],
|
||||
cleanupTargets: []
|
||||
};
|
||||
}
|
||||
|
||||
const settingsPath = getManagedDestination(
|
||||
existingDestination,
|
||||
trustedRoot,
|
||||
'uninstall'
|
||||
).managedPath;
|
||||
const uninstalled = updateSettingsAtomic(
|
||||
settingsPath,
|
||||
currentSettings => uninstallManagedHooks(currentSettings, operation.managedHooks),
|
||||
{
|
||||
lockHeld: Boolean(options.settingsLockHeld),
|
||||
beforeCommit() {
|
||||
getManagedDestination(settingsPath, trustedRoot, 'uninstall');
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
removedPaths: [],
|
||||
cleanupTargets: [],
|
||||
retainedPaths: uninstalled.retained.length > 0
|
||||
? [operation.destinationPath]
|
||||
: []
|
||||
};
|
||||
}
|
||||
|
||||
if (operation.kind === 'remove') {
|
||||
const previousContent = getOperationPreviousContent(operation);
|
||||
if (previousContent !== null) {
|
||||
@@ -966,7 +1078,7 @@ function executeUninstallOperation(operation, trustedRoot, options = {}) {
|
||||
throw new Error(`Unsupported uninstall operation kind: ${operation.kind}`);
|
||||
}
|
||||
|
||||
function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = null) {
|
||||
function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = null, target = null) {
|
||||
const destinationPath = operation.destinationPath;
|
||||
if (!destinationPath) {
|
||||
return {
|
||||
@@ -1147,6 +1259,49 @@ function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = n
|
||||
};
|
||||
}
|
||||
|
||||
if (operation.kind === 'update-claude-settings') {
|
||||
try {
|
||||
assertClaudeSettingsDestination(operation, trustedRoot, target);
|
||||
} catch (_error) {
|
||||
return {
|
||||
status: 'unsafe-destination',
|
||||
operation,
|
||||
destinationPath,
|
||||
reason: 'non-canonical-claude-settings'
|
||||
};
|
||||
}
|
||||
let managedHooks;
|
||||
try {
|
||||
managedHooks = validateManagedHooks(operation.managedHooks);
|
||||
} catch (_error) {
|
||||
return {
|
||||
status: 'unverified',
|
||||
operation,
|
||||
destinationPath
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const inspection = inspectManagedHooks(
|
||||
readJsonNoFollow(inspectedPath),
|
||||
managedHooks
|
||||
);
|
||||
return {
|
||||
status: inspection.status,
|
||||
operation,
|
||||
destinationPath,
|
||||
managedHookInspection: inspection
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'invalid-settings',
|
||||
operation,
|
||||
destinationPath,
|
||||
error: `Failed to inspect Claude settings at ${destinationPath}: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'unverified',
|
||||
operation,
|
||||
@@ -1154,11 +1309,17 @@ function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = n
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations) {
|
||||
function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations, target = null) {
|
||||
const linkIndex = buildLinkIndexForOperations(operations, trustedRoot);
|
||||
return operations.reduce(
|
||||
(summary, operation) => {
|
||||
const inspection = inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex);
|
||||
const inspection = inspectManagedOperation(
|
||||
repoRoot,
|
||||
trustedRoot,
|
||||
operation,
|
||||
linkIndex,
|
||||
target
|
||||
);
|
||||
if (inspection.status === 'missing') {
|
||||
summary.missing.push(inspection);
|
||||
} else if (inspection.status === 'drifted') {
|
||||
@@ -1169,6 +1330,8 @@ function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations) {
|
||||
summary.unsafeSource.push(inspection);
|
||||
} else if (inspection.status === 'unsafe-destination') {
|
||||
summary.unsafeDestination.push(inspection);
|
||||
} else if (inspection.status === 'invalid-settings') {
|
||||
summary.invalidSettings.push(inspection);
|
||||
} else if (inspection.status === 'unverified' || inspection.status === 'invalid-destination') {
|
||||
summary.unverified.push(inspection);
|
||||
}
|
||||
@@ -1180,6 +1343,7 @@ function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations) {
|
||||
missingSource: [],
|
||||
unsafeSource: [],
|
||||
unsafeDestination: [],
|
||||
invalidSettings: [],
|
||||
unverified: []
|
||||
}
|
||||
);
|
||||
@@ -1200,7 +1364,9 @@ function getUnsafeOperationResult(record, operationHealth) {
|
||||
? getUnsafeManagedDestinationError(operationHealth)
|
||||
: operationHealth.unsafeSource.length > 0
|
||||
? createUnsafeRepairSourceError().message
|
||||
: null;
|
||||
: operationHealth.invalidSettings.length > 0
|
||||
? operationHealth.invalidSettings[0].error
|
||||
: null;
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
@@ -1467,7 +1633,8 @@ function analyzeRecord(record, context) {
|
||||
const operationHealth = summarizeManagedOperationHealth(
|
||||
context.repoRoot,
|
||||
record.targetRoot,
|
||||
managedOperations
|
||||
managedOperations,
|
||||
record.adapter.target
|
||||
);
|
||||
const missingManagedOperations = operationHealth.missing;
|
||||
|
||||
@@ -1491,6 +1658,17 @@ function analyzeRecord(record, context) {
|
||||
);
|
||||
}
|
||||
|
||||
if (operationHealth.invalidSettings.length > 0) {
|
||||
issues.push(
|
||||
buildIssue(
|
||||
'error',
|
||||
'invalid-claude-settings',
|
||||
operationHealth.invalidSettings[0].error,
|
||||
{ paths: operationHealth.invalidSettings.map(entry => entry.destinationPath) }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (missingManagedOperations.length > 0) {
|
||||
issues.push(
|
||||
buildIssue('error', 'missing-managed-files', `${missingManagedOperations.length} managed file(s) are missing`, {
|
||||
@@ -1614,7 +1792,11 @@ function createRepairPlanFromRecord(record, context, options = {}) {
|
||||
record.legacyLayout !== 'opencode'
|
||||
&& (state.request.legacyMode || shouldRepairFromRecordedOperations(state))
|
||||
) {
|
||||
const operations = hydrateRecordedOperations(context.repoRoot, getManagedOperations(state));
|
||||
const operations = hydrateRecordedOperations(
|
||||
context.repoRoot,
|
||||
getManagedOperations(state),
|
||||
record.targetRoot
|
||||
);
|
||||
const statePreview = buildRecordedStatePreview(state, context, operations);
|
||||
|
||||
return {
|
||||
@@ -1757,7 +1939,17 @@ function repairInstalledStates(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
let releaseSettingsLock = null;
|
||||
try {
|
||||
const settingsPathToLock = !options.dryRun
|
||||
&& getManagedOperations(record.state || {}).some(
|
||||
operation => operation.kind === 'update-claude-settings'
|
||||
)
|
||||
? getClaudeSettingsPath(record.targetRoot)
|
||||
: null;
|
||||
if (settingsPathToLock) {
|
||||
releaseSettingsLock = acquireSettingsLock(settingsPathToLock);
|
||||
}
|
||||
const needsOpencodeBuild = record.adapter.target === 'opencode'
|
||||
&& hasOpencodeBuildError(getOpencodeBuildValidationIssues(context));
|
||||
const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT);
|
||||
@@ -1829,7 +2021,8 @@ function repairInstalledStates(options = {}) {
|
||||
const operationHealth = summarizeManagedOperationHealth(
|
||||
context.repoRoot,
|
||||
record.targetRoot,
|
||||
desiredPlan.operations
|
||||
desiredPlan.operations,
|
||||
record.adapter.target
|
||||
);
|
||||
const unsafeOperationResult = getUnsafeOperationResult(
|
||||
record,
|
||||
@@ -1876,7 +2069,8 @@ function repairInstalledStates(options = {}) {
|
||||
const operationHealth = summarizeManagedOperationHealth(
|
||||
context.repoRoot,
|
||||
record.targetRoot,
|
||||
desiredPlan.operations
|
||||
desiredPlan.operations,
|
||||
record.adapter.target
|
||||
);
|
||||
|
||||
const unsafeOperationResult = getUnsafeOperationResult(
|
||||
@@ -1899,7 +2093,20 @@ function repairInstalledStates(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))];
|
||||
const repairOperations = [
|
||||
...operationHealth.missing.map(entry => ({ ...entry.operation })),
|
||||
...operationHealth.drifted.map(entry => ({ ...entry.operation })),
|
||||
...desiredPlan.operations
|
||||
.filter(operation => (
|
||||
operation.kind === 'update-claude-settings'
|
||||
&& operation.previousManagedHooks
|
||||
&& !isDeepStrictEqual(operation.previousManagedHooks, operation.managedHooks)
|
||||
))
|
||||
.map(operation => ({ ...operation })),
|
||||
].filter((operation, index, items) => items.findIndex(candidate => (
|
||||
candidate.kind === operation.kind
|
||||
&& candidate.destinationPath === operation.destinationPath
|
||||
)) === index);
|
||||
const repairLinkIndex = buildLinkIndexForOperations(desiredPlan.operations, record.targetRoot);
|
||||
const legacyMigrationPaths = migration.legacyOperationsToRemove.map(
|
||||
operation => operation.destinationPath
|
||||
@@ -1934,7 +2141,9 @@ function repairInstalledStates(options = {}) {
|
||||
context.repoRoot,
|
||||
operation,
|
||||
record.targetRoot,
|
||||
repairLinkIndex
|
||||
repairLinkIndex,
|
||||
record.adapter.target,
|
||||
Boolean(releaseSettingsLock)
|
||||
);
|
||||
if (repairedPath) {
|
||||
repairedPaths.push(repairedPath);
|
||||
@@ -1986,6 +2195,8 @@ function repairInstalledStates(options = {}) {
|
||||
plannedRepairs: [],
|
||||
error: error.message
|
||||
};
|
||||
} finally {
|
||||
if (releaseSettingsLock) releaseSettingsLock();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2100,15 +2311,23 @@ function uninstallInstalledStates(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
let releaseSettingsLock = null;
|
||||
try {
|
||||
const removedPaths = [];
|
||||
const cleanupTargets = [];
|
||||
const retainedPaths = [];
|
||||
const operations = getManagedOperations(state);
|
||||
if (operations.some(operation => operation.kind === 'update-claude-settings')) {
|
||||
releaseSettingsLock = acquireSettingsLock(
|
||||
getClaudeSettingsPath(record.targetRoot)
|
||||
);
|
||||
}
|
||||
|
||||
for (const operation of operations) {
|
||||
const outcome = executeUninstallOperation(operation, record.targetRoot, {
|
||||
preserveDriftedCopies: true,
|
||||
target: record.adapter.target,
|
||||
settingsLockHeld: Boolean(releaseSettingsLock),
|
||||
});
|
||||
removedPaths.push(...outcome.removedPaths);
|
||||
cleanupTargets.push(...outcome.cleanupTargets);
|
||||
@@ -2153,6 +2372,8 @@ function uninstallInstalledStates(options = {}) {
|
||||
plannedRemovals,
|
||||
error: error.message
|
||||
};
|
||||
} finally {
|
||||
if (releaseSettingsLock) releaseSettingsLock();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const {
|
||||
CLAUDE_HOOKS_CONFIG_PATH,
|
||||
getClaudeSettingsPath,
|
||||
validateRecordedManagedHooks,
|
||||
} = require('./install/claude-settings');
|
||||
|
||||
// Dependency-free, self-contained validation. The installer closure must not
|
||||
// require any non-builtin package (enterprise supply-chain vetting: the vetted
|
||||
@@ -209,6 +214,38 @@ function createFallbackValidator() {
|
||||
) {
|
||||
pushError(`${instancePath}/contentSha256`, 'must be a SHA-256 hex digest');
|
||||
}
|
||||
if (operation.kind === 'update-claude-settings') {
|
||||
if (!['claude', 'claude-project'].includes(state.target && state.target.target)) {
|
||||
pushError(`${instancePath}/kind`, 'is only valid for Claude targets');
|
||||
}
|
||||
if (operation.moduleId !== 'hooks-runtime') {
|
||||
pushError(`${instancePath}/moduleId`, 'must equal hooks-runtime');
|
||||
}
|
||||
if (String(operation.sourceRelativePath).replace(/\\/g, '/') !== CLAUDE_HOOKS_CONFIG_PATH) {
|
||||
pushError(`${instancePath}/sourceRelativePath`, 'must equal hooks/hooks.json');
|
||||
}
|
||||
if (
|
||||
isNonEmptyString(state.target && state.target.root)
|
||||
&& isNonEmptyString(operation.destinationPath)
|
||||
) {
|
||||
const expectedDestination = path.resolve(getClaudeSettingsPath(state.target.root));
|
||||
const actualDestination = path.resolve(operation.destinationPath);
|
||||
const pathsMatch = process.platform === 'win32'
|
||||
? expectedDestination.toLowerCase() === actualDestination.toLowerCase()
|
||||
: expectedDestination === actualDestination;
|
||||
if (!pathsMatch) {
|
||||
pushError(
|
||||
`${instancePath}/destinationPath`,
|
||||
'must equal the canonical Claude settings path'
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
validateRecordedManagedHooks(operation.managedHooks);
|
||||
} catch (error) {
|
||||
pushError(`${instancePath}/managedHooks`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ const {
|
||||
createRemappedOperation,
|
||||
isForeignPlatformPath,
|
||||
normalizeRelativePath,
|
||||
planClaudeHooksOperations,
|
||||
} = require('./helpers');
|
||||
|
||||
const CLAUDE_ECC_NAMESPACE = 'ecc';
|
||||
@@ -66,7 +67,14 @@ module.exports = createInstallTargetAdapter({
|
||||
const paths = Array.isArray(module.paths) ? module.paths : [];
|
||||
return paths
|
||||
.filter(p => !isForeignPlatformPath(p, adapter.target))
|
||||
.map(sourceRelativePath => {
|
||||
.flatMap(sourceRelativePath => {
|
||||
if (
|
||||
module.id === 'hooks-runtime'
|
||||
&& normalizeRelativePath(sourceRelativePath) === 'hooks'
|
||||
) {
|
||||
return planClaudeHooksOperations(adapter, module, planningInput);
|
||||
}
|
||||
|
||||
const managedDestinationPath = getClaudeManagedDestinationPath(
|
||||
adapter,
|
||||
sourceRelativePath,
|
||||
@@ -74,16 +82,16 @@ module.exports = createInstallTargetAdapter({
|
||||
);
|
||||
|
||||
if (managedDestinationPath) {
|
||||
return createRemappedOperation(
|
||||
return [createRemappedOperation(
|
||||
adapter,
|
||||
module.id,
|
||||
sourceRelativePath,
|
||||
managedDestinationPath,
|
||||
{ strategy: 'preserve-relative-path' }
|
||||
);
|
||||
)];
|
||||
}
|
||||
|
||||
return adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput);
|
||||
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ const {
|
||||
createRemappedOperation,
|
||||
isForeignPlatformPath,
|
||||
normalizeRelativePath,
|
||||
planClaudeHooksOperations,
|
||||
} = require('./helpers');
|
||||
|
||||
const CLAUDE_ECC_NAMESPACE = 'ecc';
|
||||
@@ -66,7 +67,14 @@ module.exports = createInstallTargetAdapter({
|
||||
const paths = Array.isArray(module.paths) ? module.paths : [];
|
||||
return paths
|
||||
.filter(p => !isForeignPlatformPath(p, 'claude'))
|
||||
.map(sourceRelativePath => {
|
||||
.flatMap(sourceRelativePath => {
|
||||
if (
|
||||
module.id === 'hooks-runtime'
|
||||
&& normalizeRelativePath(sourceRelativePath) === 'hooks'
|
||||
) {
|
||||
return planClaudeHooksOperations(adapter, module, planningInput);
|
||||
}
|
||||
|
||||
const managedDestinationPath = getClaudeManagedDestinationPath(
|
||||
adapter,
|
||||
sourceRelativePath,
|
||||
@@ -74,16 +82,16 @@ module.exports = createInstallTargetAdapter({
|
||||
);
|
||||
|
||||
if (managedDestinationPath) {
|
||||
return createRemappedOperation(
|
||||
return [createRemappedOperation(
|
||||
adapter,
|
||||
module.id,
|
||||
sourceRelativePath,
|
||||
managedDestinationPath,
|
||||
{ strategy: 'preserve-relative-path' }
|
||||
);
|
||||
)];
|
||||
}
|
||||
|
||||
return adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput);
|
||||
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {
|
||||
CLAUDE_HOOKS_CONFIG_PATH,
|
||||
getClaudeSettingsPath,
|
||||
} = require('../install/claude-settings');
|
||||
|
||||
const PLATFORM_SOURCE_PATH_OWNERS = Object.freeze({
|
||||
'.claude-plugin': 'claude',
|
||||
@@ -146,6 +150,42 @@ function createRemappedOperation(adapter, moduleId, sourceRelativePath, destinat
|
||||
});
|
||||
}
|
||||
|
||||
function planClaudeHooksOperations(adapter, module, input) {
|
||||
const operations = [
|
||||
createRemappedOperation(
|
||||
adapter,
|
||||
module.id,
|
||||
CLAUDE_HOOKS_CONFIG_PATH,
|
||||
getClaudeSettingsPath(adapter.resolveRoot(input)),
|
||||
{
|
||||
kind: 'update-claude-settings',
|
||||
strategy: 'merge-hook-ids',
|
||||
}
|
||||
),
|
||||
];
|
||||
|
||||
if (!input.repoRoot) {
|
||||
return operations;
|
||||
}
|
||||
|
||||
const sourceHooksRoot = path.join(input.repoRoot, 'hooks');
|
||||
if (!fs.existsSync(sourceHooksRoot)) {
|
||||
return operations;
|
||||
}
|
||||
|
||||
return [
|
||||
...operations,
|
||||
...fs.readdirSync(sourceHooksRoot, { withFileTypes: true })
|
||||
.filter(entry => entry.name !== 'hooks.json')
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map(entry => adapter.createScaffoldOperation(
|
||||
module.id,
|
||||
path.join('hooks', entry.name),
|
||||
input
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
function createNamespacedFlatRuleOperations(adapter, moduleId, sourceRelativePath, input = {}) {
|
||||
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
||||
const sourceRoot = path.join(input.repoRoot || '', normalizedSourcePath);
|
||||
@@ -373,4 +413,5 @@ module.exports = {
|
||||
createRemappedOperation,
|
||||
isForeignPlatformPath,
|
||||
normalizeRelativePath,
|
||||
planClaudeHooksOperations,
|
||||
};
|
||||
|
||||
+264
-147
@@ -8,8 +8,18 @@ const {
|
||||
hasExplicitCommitAttributionPreference,
|
||||
withCommitAttributionDisabled,
|
||||
} = require('../claude-commit-attribution');
|
||||
const { writeInstallState } = require('../install-state');
|
||||
const { readInstallState, writeInstallState } = require('../install-state');
|
||||
const { assertHookConsentReady, planMaterializesHookRuntime } = require('./hook-consent');
|
||||
const {
|
||||
getClaudeSettingsPath,
|
||||
mergeManagedHooks,
|
||||
readSettings,
|
||||
runWithSettingsLock,
|
||||
uninstallManagedHooks,
|
||||
updateSettingsAtomic,
|
||||
validateManagedHooks,
|
||||
validateRecordedManagedHooks,
|
||||
} = require('./claude-settings');
|
||||
const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config');
|
||||
const { assertWithinTrustedRoot } = require('../path-safety');
|
||||
const {
|
||||
@@ -200,22 +210,12 @@ function shouldSetClaudeCommitAttributionPreference(plan) {
|
||||
});
|
||||
}
|
||||
|
||||
function writeClaudeCommitAttributionPreference(settingsPath) {
|
||||
// Read once rather than probing with existsSync first. Checking for the file and
|
||||
// then writing it is a file system race (CodeQL js/file-system-race), and a
|
||||
// missing file is simply the fresh-install case.
|
||||
function writeClaudeCommitAttributionPreference(settingsPath, options = {}) {
|
||||
let settings;
|
||||
try {
|
||||
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
// Unreadable or malformed settings belong to the user; leave them untouched.
|
||||
return false;
|
||||
}
|
||||
settings = {};
|
||||
}
|
||||
|
||||
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
||||
settings = readSettings(settingsPath);
|
||||
} catch (_error) {
|
||||
// Unreadable or malformed settings belong to the user; leave them untouched.
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -223,46 +223,15 @@ function writeClaudeCommitAttributionPreference(settingsPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
formatJson(withCommitAttributionDisabled(settings)),
|
||||
'utf8'
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function replacePluginRootPlaceholders(value, pluginRoot) {
|
||||
if (!pluginRoot) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.split('${CLAUDE_PLUGIN_ROOT}').join(pluginRoot);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => replacePluginRootPlaceholders(item, pluginRoot));
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, nestedValue]) => [
|
||||
key,
|
||||
replacePluginRootPlaceholders(nestedValue, pluginRoot),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function findHooksOperation(plan, hooksDestinationPath) {
|
||||
return plan.operations.find(item => (
|
||||
item.destinationPath === hooksDestinationPath
|
||||
&& item.moduleId === 'hooks-runtime'
|
||||
&& typeof item.sourcePath === 'string'
|
||||
));
|
||||
let changed = false;
|
||||
updateSettingsAtomic(settingsPath, latestSettings => {
|
||||
if (hasExplicitCommitAttributionPreference(latestSettings)) {
|
||||
return { settings: latestSettings };
|
||||
}
|
||||
changed = true;
|
||||
return { settings: withCommitAttributionDisabled(latestSettings) };
|
||||
}, options);
|
||||
return changed;
|
||||
}
|
||||
|
||||
function isMcpConfigPath(filePath) {
|
||||
@@ -302,40 +271,135 @@ function assertSafeInstallOperation(plan, operation) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildResolvedClaudeHooks(plan) {
|
||||
if (!plan.adapter || (plan.adapter.target !== 'claude' && plan.adapter.target !== 'claude-project')) {
|
||||
function readPreviousInstallState(plan) {
|
||||
if (!fs.existsSync(plan.installStatePath)) {
|
||||
return null;
|
||||
}
|
||||
return readInstallState(plan.installStatePath);
|
||||
}
|
||||
|
||||
function comparablePath(filePath) {
|
||||
const resolved = path.resolve(filePath);
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
function findPreviousManagedHooks(previousState, plan, operation) {
|
||||
if (
|
||||
!previousState
|
||||
|| previousState.target.id !== plan.adapter.id
|
||||
|| comparablePath(previousState.target.root) !== comparablePath(plan.targetRoot)
|
||||
|| comparablePath(previousState.target.installStatePath) !== comparablePath(plan.installStatePath)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pluginRoot = plan.targetRoot;
|
||||
const hooksDestinationPath = path.join(plan.targetRoot, 'hooks', 'hooks.json');
|
||||
const hooksOperation = findHooksOperation(plan, hooksDestinationPath);
|
||||
if (!hooksOperation) {
|
||||
return null;
|
||||
}
|
||||
const hooksSourcePath = hooksOperation.sourcePath;
|
||||
if (!fs.existsSync(hooksSourcePath)) {
|
||||
const previousOperation = (previousState.operations || []).find(candidate => (
|
||||
candidate.kind === operation.kind
|
||||
&& comparablePath(candidate.destinationPath) === comparablePath(operation.destinationPath)
|
||||
));
|
||||
if (!previousOperation || !previousOperation.managedHooks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hooksConfig = readJsonObject(hooksSourcePath, 'hooks config');
|
||||
const resolvedHooks = replacePluginRootPlaceholders(hooksConfig.hooks, pluginRoot);
|
||||
if (!resolvedHooks || typeof resolvedHooks !== 'object' || Array.isArray(resolvedHooks)) {
|
||||
throw new Error(`Invalid hooks config at ${hooksSourcePath}: expected "hooks" to be a JSON object`);
|
||||
return validateRecordedManagedHooks(
|
||||
previousOperation.managedHooks,
|
||||
'previous managed hooks'
|
||||
);
|
||||
}
|
||||
|
||||
function preflightClaudeSettingsOperations(plan) {
|
||||
const settingsOperations = plan.operations.filter(operation => (
|
||||
operation.kind === 'update-claude-settings'
|
||||
|| operation.kind === 'remove-claude-settings-hooks'
|
||||
));
|
||||
if (settingsOperations.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const previousState = readPreviousInstallState(plan);
|
||||
return new Map(settingsOperations.map(operation => {
|
||||
assertSafeInstallOperation(plan, operation);
|
||||
const managedHooks = validateManagedHooks(operation.managedHooks);
|
||||
const settings = readSettings(operation.destinationPath);
|
||||
const previousManagedHooks = findPreviousManagedHooks(previousState, plan, operation);
|
||||
if (operation.kind === 'remove-claude-settings-hooks') {
|
||||
const removal = uninstallManagedHooks(settings, managedHooks);
|
||||
if (removal.retained.length > 0) {
|
||||
throw new Error(
|
||||
`Refusing to disable modified Claude hooks in ${operation.destinationPath}; `
|
||||
+ 'run the ECC uninstaller to review retained entries.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
mergeManagedHooks(settings, managedHooks, { previousManagedHooks });
|
||||
}
|
||||
return [operation, { managedHooks, previousManagedHooks }];
|
||||
}));
|
||||
}
|
||||
|
||||
function prepareHookConsentMigration(plan, migration) {
|
||||
if (plan.hookConsent !== 'declined') {
|
||||
return migration;
|
||||
}
|
||||
const previousState = readPreviousInstallState(plan);
|
||||
if (!previousState) {
|
||||
return migration;
|
||||
}
|
||||
|
||||
const removals = (previousState.operations || [])
|
||||
.filter(operation => operation.kind === 'update-claude-settings')
|
||||
.map(operation => ({
|
||||
...operation,
|
||||
kind: 'remove-claude-settings-hooks',
|
||||
strategy: 'remove-hook-ids',
|
||||
scaffoldOnly: false,
|
||||
}));
|
||||
if (removals.length === 0) {
|
||||
return migration;
|
||||
}
|
||||
const removalDestinations = new Set(removals.map(operation => comparablePath(
|
||||
operation.destinationPath
|
||||
)));
|
||||
return {
|
||||
hooksOperation,
|
||||
hooksDestinationPath,
|
||||
resolvedHooksConfig: {
|
||||
...hooksConfig,
|
||||
hooks: resolvedHooks,
|
||||
...migration,
|
||||
// Disable hooks only after every ordinary install operation succeeds so a
|
||||
// partial reinstall cannot silently revoke working hooks before failing.
|
||||
appliedOperations: [...migration.appliedOperations, ...removals],
|
||||
finalState: {
|
||||
...migration.finalState,
|
||||
operations: migration.finalState.operations.filter(operation => !(
|
||||
operation.kind === 'update-claude-settings'
|
||||
&& removalDestinations.has(comparablePath(operation.destinationPath))
|
||||
)),
|
||||
},
|
||||
bridgeState: {
|
||||
...migration.bridgeState,
|
||||
request: {
|
||||
...migration.bridgeState.request,
|
||||
hookConsent: 'enabled',
|
||||
},
|
||||
resolution: {
|
||||
...migration.bridgeState.resolution,
|
||||
selectedModules: [...new Set([
|
||||
...migration.bridgeState.resolution.selectedModules,
|
||||
'hooks-runtime',
|
||||
])],
|
||||
},
|
||||
},
|
||||
requiresBridgeState: true,
|
||||
};
|
||||
}
|
||||
|
||||
function previewInstallPlan(plan) {
|
||||
const migration = prepareClaudeSkillMigration(plan);
|
||||
const migration = prepareHookConsentMigration(
|
||||
plan,
|
||||
prepareClaudeSkillMigration(plan)
|
||||
);
|
||||
const appliedPlan = {
|
||||
...plan,
|
||||
operations: migration.appliedOperations,
|
||||
};
|
||||
preflightClaudeSettingsOperations(appliedPlan);
|
||||
const hookConsentWarnings = planMaterializesHookRuntime(plan) && plan.hookConsent !== 'enabled'
|
||||
? ['Applying this plan requires an explicit hook decision: --enable-hooks or --no-hooks.']
|
||||
: [];
|
||||
@@ -356,6 +420,23 @@ function previewInstallPlan(plan) {
|
||||
|
||||
function applyInstallPlan(plan, dependencies = {}) {
|
||||
assertHookConsentReady(plan);
|
||||
const isClaudeManualTarget = plan.adapter
|
||||
&& (plan.adapter.target === 'claude' || plan.adapter.target === 'claude-project');
|
||||
const settingsPathToLock = isClaudeManualTarget
|
||||
? getClaudeSettingsPath(plan.targetRoot)
|
||||
: null;
|
||||
if (settingsPathToLock) {
|
||||
assertSafeInstallOperation(plan, { destinationPath: settingsPathToLock });
|
||||
}
|
||||
return settingsPathToLock
|
||||
? runWithSettingsLock(
|
||||
settingsPathToLock,
|
||||
() => applyInstallPlanLocked(plan, dependencies, true)
|
||||
)
|
||||
: applyInstallPlanLocked(plan, dependencies, false);
|
||||
}
|
||||
|
||||
function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = false) {
|
||||
const persistInstallState = dependencies.writeInstallState || writeInstallState;
|
||||
const beforeInstallStateRead = dependencies.beforeInstallStateRead;
|
||||
const beforeOperationWrite = dependencies.beforeOperationWrite;
|
||||
@@ -363,30 +444,36 @@ function applyInstallPlan(plan, dependencies = {}) {
|
||||
if (typeof beforeInstallStateRead === 'function') {
|
||||
beforeInstallStateRead({ plan });
|
||||
}
|
||||
const migration = prepareClaudeSkillMigration(plan);
|
||||
const migration = prepareHookConsentMigration(
|
||||
plan,
|
||||
prepareClaudeSkillMigration(plan)
|
||||
);
|
||||
const appliedPlan = {
|
||||
...plan,
|
||||
operations: migration.appliedOperations,
|
||||
};
|
||||
const resolvedClaudeHooksPlan = buildResolvedClaudeHooks(appliedPlan);
|
||||
const preparedClaudeSettings = preflightClaudeSettingsOperations(appliedPlan);
|
||||
const disabledServers = parseDisabledMcpServers(process.env.ECC_DISABLED_MCPS);
|
||||
const linkIndex = buildLinkIndexForPlan(appliedPlan);
|
||||
const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0;
|
||||
|
||||
if (migration.requiresBridgeState) {
|
||||
// Own every operation that may be written during a flat-skill migration
|
||||
// before the first copy. A later failure is retryable and uninstall can
|
||||
// clean the entire partial install, including non-skill files. During
|
||||
// legacy migration the bridge also retains the prior managed operations.
|
||||
if (typeof beforeInstallStateWrite === 'function') {
|
||||
beforeInstallStateWrite({ plan: appliedPlan, state: migration.bridgeState });
|
||||
const hookRemovalCount = appliedPlan.operations.filter(operation => (
|
||||
operation.kind === 'remove-claude-settings-hooks'
|
||||
)).length;
|
||||
let completedHookRemovalCount = 0;
|
||||
if (migration.requiresBridgeState) {
|
||||
// Own every operation that may be written during a flat-skill migration
|
||||
// before the first copy. A later failure is retryable and uninstall can
|
||||
// clean the entire partial install, including non-skill files. During
|
||||
// legacy migration the bridge also retains the prior managed operations.
|
||||
if (typeof beforeInstallStateWrite === 'function') {
|
||||
beforeInstallStateWrite({ plan: appliedPlan, state: migration.bridgeState });
|
||||
}
|
||||
persistInstallState(plan.installStatePath, migration.bridgeState);
|
||||
}
|
||||
persistInstallState(plan.installStatePath, migration.bridgeState);
|
||||
}
|
||||
|
||||
let finalState;
|
||||
try {
|
||||
for (const operation of appliedPlan.operations) {
|
||||
let finalState;
|
||||
try {
|
||||
for (const operation of appliedPlan.operations) {
|
||||
assertSafeInstallOperation(appliedPlan, operation);
|
||||
assertSafeClaudeSkillOperation(appliedPlan, operation);
|
||||
fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true });
|
||||
@@ -399,6 +486,42 @@ function applyInstallPlan(plan, dependencies = {}) {
|
||||
beforeOperationWrite({ plan: appliedPlan, operation });
|
||||
}
|
||||
|
||||
if (
|
||||
operation.kind === 'update-claude-settings'
|
||||
|| operation.kind === 'remove-claude-settings-hooks'
|
||||
) {
|
||||
// Re-read at the write boundary so unrelated settings added after
|
||||
// planning are preserved. A same-ID change still fails closed.
|
||||
const prepared = preparedClaudeSettings.get(operation);
|
||||
assertSafeInstallOperation(appliedPlan, operation);
|
||||
updateSettingsAtomic(operation.destinationPath, latestSettings => {
|
||||
const merged = operation.kind === 'remove-claude-settings-hooks'
|
||||
? uninstallManagedHooks(latestSettings, prepared.managedHooks)
|
||||
: mergeManagedHooks(latestSettings, prepared.managedHooks, {
|
||||
previousManagedHooks: prepared.previousManagedHooks,
|
||||
});
|
||||
if (
|
||||
operation.kind === 'remove-claude-settings-hooks'
|
||||
&& merged.retained.length > 0
|
||||
) {
|
||||
throw new Error(
|
||||
`Refusing to disable modified Claude hooks in ${operation.destinationPath}; `
|
||||
+ 'run the ECC uninstaller to review retained entries.'
|
||||
);
|
||||
}
|
||||
return merged;
|
||||
}, {
|
||||
lockHeld: settingsLockHeld,
|
||||
beforeCommit() {
|
||||
assertSafeInstallOperation(appliedPlan, operation);
|
||||
},
|
||||
});
|
||||
if (operation.kind === 'remove-claude-settings-hooks') {
|
||||
completedHookRemovalCount += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (operation.kind === 'merge-json') {
|
||||
const payload = cloneJsonValue(operation.mergePayload);
|
||||
if (payload === undefined) {
|
||||
@@ -450,55 +573,49 @@ function applyInstallPlan(plan, dependencies = {}) {
|
||||
}
|
||||
|
||||
fs.copyFileSync(operation.sourcePath, operation.destinationPath);
|
||||
}
|
||||
|
||||
if (resolvedClaudeHooksPlan) {
|
||||
assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation);
|
||||
fs.mkdirSync(path.dirname(resolvedClaudeHooksPlan.hooksDestinationPath), { recursive: true });
|
||||
assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation);
|
||||
if (typeof beforeOperationWrite === 'function') {
|
||||
beforeOperationWrite({ plan: appliedPlan, operation: resolvedClaudeHooksPlan.hooksOperation });
|
||||
}
|
||||
fs.writeFileSync(
|
||||
resolvedClaudeHooksPlan.hooksDestinationPath,
|
||||
JSON.stringify(resolvedClaudeHooksPlan.resolvedHooksConfig, null, 2) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
if (hasLegacyMigration) {
|
||||
removeLegacyClaudeSkillFiles(migration, plan.targetRoot);
|
||||
}
|
||||
if (hasLegacyMigration) {
|
||||
removeLegacyClaudeSkillFiles(migration, plan.targetRoot);
|
||||
}
|
||||
|
||||
if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) {
|
||||
writeClaudeCommitAttributionPreference(path.join(plan.targetRoot, 'settings.json'));
|
||||
}
|
||||
|
||||
finalState = stateWithContentDigests(migration.finalState, appliedPlan);
|
||||
if (typeof beforeInstallStateWrite === 'function') {
|
||||
beforeInstallStateWrite({ plan: appliedPlan, state: finalState });
|
||||
}
|
||||
persistInstallState(plan.installStatePath, finalState);
|
||||
} catch (error) {
|
||||
if (migration.requiresBridgeState) {
|
||||
try {
|
||||
// The bridge was committed before any writes. Refresh it with hashes of
|
||||
// files that now exist so uninstall can remove only bytes this attempt
|
||||
// actually installed while preserving user changes.
|
||||
persistInstallState(
|
||||
plan.installStatePath,
|
||||
stateWithContentDigests(migration.bridgeState, appliedPlan)
|
||||
);
|
||||
} catch (checkpointError) {
|
||||
throw new Error(
|
||||
`${error.message} Install-state checkpoint also failed: ${checkpointError.message}`,
|
||||
{ cause: error }
|
||||
if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) {
|
||||
writeClaudeCommitAttributionPreference(
|
||||
getClaudeSettingsPath(plan.targetRoot),
|
||||
{ lockHeld: settingsLockHeld }
|
||||
);
|
||||
}
|
||||
|
||||
finalState = stateWithContentDigests(migration.finalState, appliedPlan);
|
||||
if (typeof beforeInstallStateWrite === 'function') {
|
||||
beforeInstallStateWrite({ plan: appliedPlan, state: finalState });
|
||||
}
|
||||
persistInstallState(plan.installStatePath, finalState);
|
||||
} catch (error) {
|
||||
if (migration.requiresBridgeState) {
|
||||
try {
|
||||
// The bridge was committed before any writes. Refresh it with hashes of
|
||||
// files that now exist so uninstall can remove only bytes this attempt
|
||||
// actually installed while preserving user changes.
|
||||
persistInstallState(
|
||||
plan.installStatePath,
|
||||
stateWithContentDigests(
|
||||
hookRemovalCount > 0 && completedHookRemovalCount === hookRemovalCount
|
||||
? migration.finalState
|
||||
: migration.bridgeState,
|
||||
appliedPlan
|
||||
)
|
||||
);
|
||||
} catch (checkpointError) {
|
||||
throw new Error(
|
||||
`${error.message} Install-state checkpoint also failed: ${checkpointError.message}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
let antigravityMigrationWarnings = [];
|
||||
let antigravityMigrationWarnings = [];
|
||||
try {
|
||||
const antigravityMigration = cleanupLegacyAntigravityInstall(appliedPlan);
|
||||
if (antigravityMigration.detected && !antigravityMigration.complete) {
|
||||
@@ -528,20 +645,20 @@ function applyInstallPlan(plan, dependencies = {}) {
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
...plan,
|
||||
statePreview: finalState,
|
||||
plannedOperations: [...plan.operations],
|
||||
operations: migration.appliedOperations,
|
||||
skippedOperations: migration.skippedOperations,
|
||||
warnings: [
|
||||
...(Array.isArray(plan.warnings) ? plan.warnings : []),
|
||||
...migration.warnings,
|
||||
...antigravityMigrationWarnings,
|
||||
...opencodeMigrationWarnings,
|
||||
],
|
||||
applied: true,
|
||||
};
|
||||
return {
|
||||
...plan,
|
||||
statePreview: finalState,
|
||||
plannedOperations: [...plan.operations],
|
||||
operations: migration.appliedOperations,
|
||||
skippedOperations: migration.skippedOperations,
|
||||
warnings: [
|
||||
...(Array.isArray(plan.warnings) ? plan.warnings : []),
|
||||
...migration.warnings,
|
||||
...antigravityMigrationWarnings,
|
||||
...opencodeMigrationWarnings,
|
||||
],
|
||||
applied: true,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const INVALID_LOCK_STALE_MS = 5 * 60 * 1000;
|
||||
|
||||
function sameFileIdentity(left, right) {
|
||||
return left.dev === right.dev && left.ino === right.ino;
|
||||
}
|
||||
|
||||
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;
|
||||
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}`);
|
||||
}
|
||||
released = true;
|
||||
fs.rmSync(quarantinePath, { force: true });
|
||||
};
|
||||
}
|
||||
|
||||
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 runWithSettingsLock(settingsPath, callback) {
|
||||
const releaseLock = acquireSettingsLock(settingsPath);
|
||||
let primaryError = null;
|
||||
let result;
|
||||
try {
|
||||
result = callback();
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
}
|
||||
|
||||
let releaseError = null;
|
||||
try {
|
||||
releaseLock();
|
||||
} catch (error) {
|
||||
releaseError = error;
|
||||
}
|
||||
|
||||
if (primaryError) {
|
||||
if (releaseError) primaryError.releaseError = releaseError;
|
||||
throw primaryError;
|
||||
}
|
||||
if (releaseError) throw releaseError;
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
acquireSettingsLock,
|
||||
runWithSettingsLock,
|
||||
};
|
||||
@@ -0,0 +1,693 @@
|
||||
'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,
|
||||
};
|
||||
@@ -44,7 +44,10 @@ function normalizeOperationPath(value) {
|
||||
}
|
||||
|
||||
function isHookRuntimeOperation(operation = {}) {
|
||||
if (operation.moduleId === HOOK_RUNTIME_MODULE_ID) {
|
||||
if (
|
||||
operation.kind === 'update-claude-settings'
|
||||
|| operation.moduleId === HOOK_RUNTIME_MODULE_ID
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ const { execFileSync } = require('child_process');
|
||||
const { resolveInstallPlan } = require('../install-manifests');
|
||||
const { getInstallTargetAdapter } = require('../install-targets/registry');
|
||||
const { resolveInvocationEnvironment } = require('../invocation-environment');
|
||||
const {
|
||||
materializeManagedHooks,
|
||||
} = require('./claude-settings');
|
||||
|
||||
const EXCLUDED_GENERATED_SOURCE_SUFFIXES = ['/ecc-install-state.json', '/ecc/install-state.json'];
|
||||
const IGNORED_DIRECTORY_NAMES = new Set([
|
||||
@@ -127,7 +130,31 @@ function readJsonObject(filePath, label) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function materializeClaudeSettingsOperation(sourceRoot, operation) {
|
||||
const sourcePath = path.join(sourceRoot, operation.sourceRelativePath);
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hooksConfig = readJsonObject(sourcePath, operation.sourceRelativePath);
|
||||
const managedHooks = materializeManagedHooks(
|
||||
hooksConfig,
|
||||
path.dirname(operation.destinationPath)
|
||||
);
|
||||
|
||||
return [{
|
||||
...operation,
|
||||
sourcePath,
|
||||
scaffoldOnly: false,
|
||||
managedHooks,
|
||||
}];
|
||||
}
|
||||
|
||||
function materializeScaffoldOperation(sourceRoot, operation) {
|
||||
if (operation.kind === 'update-claude-settings') {
|
||||
return materializeClaudeSettingsOperation(sourceRoot, operation);
|
||||
}
|
||||
|
||||
if (operation.kind === 'merge-json') {
|
||||
return [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user