2026-03-14 12:55:25 -07:00
'use strict' ;
2026-08-06 15:39:49 -04:00
const crypto = require ( 'crypto' );
2026-03-14 12:55:25 -07:00
const fs = require ( 'fs' );
2026-03-27 07:52:03 -04:00
const path = require ( 'path' );
2026-03-14 12:55:25 -07:00
2026-08-12 00:43:58 -04:00
const {
hasExplicitCommitAttributionPreference ,
withCommitAttributionDisabled ,
} = require ( '../claude-commit-attribution' );
2026-03-14 12:55:25 -07:00
const { writeInstallState } = require ( '../install-state' );
2026-04-05 14:37:28 -07:00
const { filterMcpConfig , parseDisabledMcpServers } = require ( '../mcp-config' );
2026-08-06 15:39:49 -04:00
const { assertWithinTrustedRoot } = require ( '../path-safety' );
2026-07-26 03:20:06 -07:00
const {
assertSafeClaudeSkillOperation ,
prepareClaudeSkillMigration ,
removeLegacyClaudeSkillFiles ,
} = require ( './claude-skill-migration' );
2026-08-13 16:42:51 -04:00
const { cleanupLegacyAntigravityInstall } = require ( './antigravity-legacy-migration' );
2026-07-26 03:20:06 -07:00
const { buildInstallIndex , rewriteRelativeLinks } = require ( './link-rewrite' );
2026-08-13 16:42:51 -04:00
const { adaptAntigravityAgent } = require ( './antigravity-agent' );
2026-07-04 09:09:24 +05:30
function isMarkdownPath ( filePath ) {
return /\.(md|mdx|markdown)$/i . test ( String ( filePath || '' ));
}
2026-08-13 16:42:51 -04:00
function transformInstallContent ( operation , content ) {
if ( ! operation . contentTransform ) {
return content ;
}
if ( operation . contentTransform === 'antigravity-agent-frontmatter' ) {
return adaptAntigravityAgent ( content , operation . sourceRelativePath );
}
throw new Error ( `Unknown install content transform: ${ operation . contentTransform } ` );
}
2026-07-04 09:09:24 +05:30
// Map every copy-file operation to { sourceRel, destRel } so relative links in
// namespaced markdown can be rewritten to the file's actual installed location
// (issue #2340). Returns null when the plan lacks the data needed to do so.
function buildLinkIndexForPlan ( plan ) {
if ( ! plan || ! plan . targetRoot || ! Array . isArray ( plan . operations )) {
return null ;
}
const mappings = [];
for ( const operation of plan . operations ) {
if ( operation . kind === 'copy-file' && operation . sourceRelativePath ) {
mappings . push ({
sourceRel : operation . sourceRelativePath ,
destRel : path . relative ( plan . targetRoot , operation . destinationPath ),
});
}
}
return buildInstallIndex ( mappings );
}
2026-03-14 12:55:25 -07:00
2026-03-28 19:55:28 -04:00
function readJsonObject ( filePath , label ) {
let parsed ;
try {
parsed = JSON . parse ( fs . readFileSync ( filePath , 'utf8' ));
} catch ( error ) {
2026-08-13 18:21:21 -04:00
const wrappedError = new Error ( `Failed to parse ${ label } at ${ filePath } : ${ error . message } ` );
wrappedError . code = error . code ;
throw wrappedError ;
2026-03-28 19:55:28 -04:00
}
if ( ! parsed || typeof parsed !== 'object' || Array . isArray ( parsed )) {
throw new Error ( `Invalid ${ label } at ${ filePath } : expected a JSON object` );
}
return parsed ;
}
2026-08-13 18:21:21 -04:00
function readOptionalJsonObject ( filePath , label ) {
try {
return readJsonObject ( filePath , label );
} catch ( error ) {
if ( error . code === 'ENOENT' ) {
return {};
}
throw error ;
}
}
function readInstalledFileNoFollow ( plan , operation ) {
assertSafeInstallOperation ( plan , operation );
assertSafeClaudeSkillOperation ( plan , operation );
const flags = fs . constants . O_RDONLY | ( fs . constants . O_NOFOLLOW || 0 );
let descriptor ;
try {
descriptor = fs . openSync ( operation . destinationPath , flags );
} catch ( error ) {
if ( error . code === 'ENOENT' ) {
return null ;
}
throw error ;
}
try {
2026-08-13 18:29:21 -04:00
const openedStat = fs . fstatSync ( descriptor , { bigint : true });
const finalPathStat = fs . lstatSync ( operation . destinationPath , { bigint : true });
if ( finalPathStat . isSymbolicLink () || ! finalPathStat . isFile ()) {
return null ;
}
const identityMatches = openedStat . ino === finalPathStat . ino
&& ( ! openedStat . dev || ! finalPathStat . dev || openedStat . dev === finalPathStat . dev );
if ( ! openedStat . isFile () || ! identityMatches ) {
throw new Error (
`Refusing to hash changed install destination: ${ operation . destinationPath } `
);
}
2026-08-13 18:21:21 -04:00
// Revalidate the full path after opening. The descriptor pins the file so
// the digest and metadata refer to the same object.
assertSafeInstallOperation ( plan , operation );
assertSafeClaudeSkillOperation ( plan , operation );
return fs . readFileSync ( descriptor );
} finally {
fs . closeSync ( descriptor );
}
}
2026-08-13 18:17:26 -04:00
function stateWithContentDigests ( state , plan ) {
2026-08-06 15:39:49 -04:00
return {
... state ,
operations : ( state . operations || []). map ( operation => {
2026-08-13 18:17:26 -04:00
if ( ! operation . destinationPath ) {
return { ... operation };
}
2026-08-13 18:21:21 -04:00
const installedContent = readInstalledFileNoFollow ( plan , operation );
if ( installedContent === null ) {
2026-08-06 15:39:49 -04:00
return { ... operation };
}
return {
... operation ,
contentSha256 : crypto . createHash ( 'sha256' )
2026-08-13 18:21:21 -04:00
. update ( installedContent )
2026-08-06 15:39:49 -04:00
. digest ( 'hex' ),
};
}),
};
}
2026-04-21 18:35:21 -04:00
function cloneJsonValue ( value ) {
if ( value === undefined ) {
return undefined ;
}
return JSON . parse ( JSON . stringify ( value ));
}
function isPlainObject ( value ) {
return Boolean ( value ) && typeof value === 'object' && ! Array . isArray ( value );
}
function deepMergeJson ( baseValue , patchValue ) {
if ( ! isPlainObject ( baseValue ) || ! isPlainObject ( patchValue )) {
return cloneJsonValue ( patchValue );
}
const merged = { ... baseValue };
for ( const [ key , value ] of Object . entries ( patchValue )) {
if ( isPlainObject ( value ) && isPlainObject ( merged [ key ])) {
merged [ key ] = deepMergeJson ( merged [ key ], value );
} else {
merged [ key ] = cloneJsonValue ( value );
}
}
return merged ;
}
function formatJson ( value ) {
return ` ${ JSON . stringify ( value , null , 2 ) } \n` ;
}
2026-08-12 00:43:58 -04:00
function shouldSetClaudeCommitAttributionPreference ( plan ) {
if ( ! plan ? . adapter || ! [ 'claude' , 'claude-project' ]. includes ( plan . adapter . target )) {
return false ;
}
return plan . operations . some ( operation => {
if ( typeof operation ? . destinationPath !== 'string' ) {
return false ;
}
const relativePath = path . relative ( plan . targetRoot , operation . destinationPath );
return relativePath && ! relativePath . startsWith ( `docs ${ path . sep } ` ) && relativePath !== 'docs' ;
});
}
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.
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 )) {
return false ;
}
if ( hasExplicitCommitAttributionPreference ( settings )) {
return false ;
}
fs . mkdirSync ( path . dirname ( settingsPath ), { recursive : true });
fs . writeFileSync (
settingsPath ,
formatJson ( withCommitAttributionDisabled ( settings )),
'utf8'
);
return true ;
}
2026-04-01 16:04:56 -07:00
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 ;
}
2026-08-06 15:39:49 -04:00
function findHooksOperation ( plan , hooksDestinationPath ) {
return plan . operations . find ( item => (
item . destinationPath === hooksDestinationPath
&& item . moduleId === 'hooks-runtime'
&& typeof item . sourcePath === 'string'
));
2026-03-28 19:55:28 -04:00
}
2026-04-05 14:37:28 -07:00
function isMcpConfigPath ( filePath ) {
const basename = path . basename ( String ( filePath || '' ));
return basename === '.mcp.json' || basename === 'mcp.json' ;
}
2026-08-06 15:39:49 -04:00
function assertSafeInstallOperation ( plan , operation ) {
if ( ! operation || typeof operation . destinationPath !== 'string' ) {
throw new Error ( 'Refusing to apply install operation: missing destination path.' );
}
const targetRoot = plan && plan . targetRoot ;
assertWithinTrustedRoot ( operation . destinationPath , targetRoot , 'install ECC file' );
const resolvedRoot = path . resolve ( targetRoot );
const resolvedTarget = path . resolve ( operation . destinationPath );
const relativePath = path . relative ( resolvedRoot , resolvedTarget );
const segments = relativePath ? relativePath . split ( path . sep ) : [];
for ( const segmentIndex of Array . from ({ length : segments . length + 1 }, ( _value , index ) => index )) {
const currentPath = segmentIndex === 0
? resolvedRoot
: path . join ( resolvedRoot , ... segments . slice ( 0 , segmentIndex ));
try {
const stats = fs . lstatSync ( currentPath );
if ( stats . isSymbolicLink ()) {
throw new Error (
`Refusing to install ECC file through symlinked path: ' ${ currentPath } '.`
);
}
} catch ( error ) {
if ( error && error . code === 'ENOENT' ) {
break ;
}
throw error ;
}
}
}
2026-04-12 22:39:48 -07:00
function buildResolvedClaudeHooks ( plan ) {
2026-05-19 19:39:18 +04:00
if ( ! plan . adapter || ( plan . adapter . target !== 'claude' && plan . adapter . target !== 'claude-project' )) {
2026-03-28 19:55:28 -04:00
return null ;
2026-03-27 07:52:03 -04:00
}
2026-04-01 16:04:56 -07:00
const pluginRoot = plan . targetRoot ;
2026-03-28 19:55:28 -04:00
const hooksDestinationPath = path . join ( plan . targetRoot , 'hooks' , 'hooks.json' );
2026-08-06 15:39:49 -04:00
const hooksOperation = findHooksOperation ( plan , hooksDestinationPath );
if ( ! hooksOperation ) {
return null ;
}
const hooksSourcePath = hooksOperation . sourcePath ;
2026-03-28 19:55:28 -04:00
if ( ! fs . existsSync ( hooksSourcePath )) {
return null ;
2026-03-27 07:52:03 -04:00
}
2026-03-28 19:55:28 -04:00
const hooksConfig = readJsonObject ( hooksSourcePath , 'hooks config' );
2026-04-12 22:39:48 -07:00
const resolvedHooks = replacePluginRootPlaceholders ( hooksConfig . hooks , pluginRoot );
if ( ! resolvedHooks || typeof resolvedHooks !== 'object' || Array . isArray ( resolvedHooks )) {
2026-03-28 19:55:28 -04:00
throw new Error ( `Invalid hooks config at ${ hooksSourcePath } : expected "hooks" to be a JSON object` );
2026-03-27 07:52:03 -04:00
}
2026-03-28 19:55:28 -04:00
return {
2026-08-06 15:39:49 -04:00
hooksOperation ,
2026-04-01 16:04:56 -07:00
hooksDestinationPath ,
resolvedHooksConfig : {
... hooksConfig ,
2026-04-12 22:39:48 -07:00
hooks : resolvedHooks ,
2026-04-01 16:04:56 -07:00
},
2026-03-28 19:55:28 -04:00
};
2026-03-27 07:52:03 -04:00
}
2026-07-26 03:20:06 -07:00
function previewInstallPlan ( plan ) {
const migration = prepareClaudeSkillMigration ( plan );
return {
... plan ,
statePreview : migration . finalState ,
plannedOperations : [... plan . operations ],
operations : migration . appliedOperations ,
skippedOperations : migration . skippedOperations ,
warnings : [
...( Array . isArray ( plan . warnings ) ? plan . warnings : []),
... migration . warnings ,
],
applied : false ,
};
}
2026-03-28 19:55:28 -04:00
2026-07-26 03:20:06 -07:00
function applyInstallPlan ( plan , dependencies = {}) {
const persistInstallState = dependencies . writeInstallState || writeInstallState ;
2026-08-06 15:39:49 -04:00
const beforeOperationWrite = dependencies . beforeOperationWrite ;
const beforeInstallStateWrite = dependencies . beforeInstallStateWrite ;
2026-07-26 03:20:06 -07:00
const migration = prepareClaudeSkillMigration ( plan );
const appliedPlan = {
... plan ,
operations : migration . appliedOperations ,
};
const resolvedClaudeHooksPlan = buildResolvedClaudeHooks ( 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.
2026-08-06 15:39:49 -04:00
if ( typeof beforeInstallStateWrite === 'function' ) {
beforeInstallStateWrite ({ plan : appliedPlan , state : migration . bridgeState });
}
2026-07-26 03:20:06 -07:00
persistInstallState ( plan . installStatePath , migration . bridgeState );
}
2026-08-13 18:17:26 -04:00
let finalState ;
try {
for ( const operation of appliedPlan . operations ) {
assertSafeInstallOperation ( appliedPlan , operation );
assertSafeClaudeSkillOperation ( appliedPlan , operation );
fs . mkdirSync ( path . dirname ( operation . destinationPath ), { recursive : true });
// Recheck directories that were absent during the first validation. This
// narrows the symlink-swap window around mkdirSync, but path checks cannot
// eliminate a later TOCTOU race before the file write.
assertSafeInstallOperation ( appliedPlan , operation );
assertSafeClaudeSkillOperation ( appliedPlan , operation );
if ( typeof beforeOperationWrite === 'function' ) {
beforeOperationWrite ({ plan : appliedPlan , operation });
2026-04-21 18:35:21 -04:00
}
2026-08-13 18:17:26 -04:00
if ( operation . kind === 'merge-json' ) {
const payload = cloneJsonValue ( operation . mergePayload );
if ( payload === undefined ) {
throw new Error ( `Missing merge payload for ${ operation . destinationPath } ` );
}
2026-04-21 18:35:21 -04:00
2026-08-13 18:17:26 -04:00
const filteredPayload = (
isMcpConfigPath ( operation . destinationPath ) && disabledServers . length > 0
)
? filterMcpConfig ( payload , disabledServers ). config
: payload ;
2026-04-21 18:35:21 -04:00
2026-08-13 18:21:21 -04:00
const currentValue = readOptionalJsonObject (
operation . destinationPath ,
'existing JSON config'
);
2026-08-13 18:17:26 -04:00
const mergedValue = deepMergeJson ( currentValue , filteredPayload );
fs . writeFileSync ( operation . destinationPath , formatJson ( mergedValue ), 'utf8' );
continue ;
}
2026-04-21 18:35:21 -04:00
2026-08-13 18:17:26 -04:00
if ( operation . kind === 'copy-file' && isMcpConfigPath ( operation . destinationPath ) && disabledServers . length > 0 ) {
const sourceConfig = readJsonObject ( operation . sourcePath , 'MCP config' );
const filteredConfig = filterMcpConfig ( sourceConfig , disabledServers ). config ;
fs . writeFileSync ( operation . destinationPath , formatJson ( filteredConfig ), 'utf8' );
continue ;
}
// Declared transforms are part of the install contract and always apply.
// Markdown link rewriting is additive when the plan has a usable index.
const needsLinkRewrite = Boolean (
linkIndex
&& operation . sourceRelativePath
&& isMarkdownPath ( operation . destinationPath )
2026-07-04 09:09:24 +05:30
);
2026-08-13 18:17:26 -04:00
if ( operation . kind === 'copy-file' && ( operation . contentTransform || needsLinkRewrite )) {
const transformed = transformInstallContent (
operation ,
fs . readFileSync ( operation . sourcePath , 'utf8' )
);
const installedContent = needsLinkRewrite
? rewriteRelativeLinks ( transformed , {
sourceRel : operation . sourceRelativePath ,
index : linkIndex ,
})
: transformed ;
fs . writeFileSync ( operation . destinationPath , installedContent , 'utf8' );
continue ;
}
fs . copyFileSync ( operation . sourcePath , operation . destinationPath );
2026-07-04 09:09:24 +05:30
}
2026-08-13 18:17:26 -04:00
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'
);
2026-08-06 15:39:49 -04:00
}
2026-03-28 19:55:28 -04:00
2026-08-13 18:17:26 -04:00
if ( hasLegacyMigration ) {
removeLegacyClaudeSkillFiles ( migration , plan . targetRoot );
}
2026-08-12 00:43:58 -04:00
2026-08-13 18:17:26 -04:00
if ( shouldSetClaudeCommitAttributionPreference ( appliedPlan )) {
writeClaudeCommitAttributionPreference ( path . join ( plan . targetRoot , 'settings.json' ));
}
2026-08-12 00:43:58 -04:00
2026-08-13 18:17:26 -04:00
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 ) {
2026-08-13 18:29:21 -04:00
throw new Error (
` ${ error . message } Install-state checkpoint also failed: ${ checkpointError . message } ` ,
{ cause : error }
);
2026-08-13 18:17:26 -04:00
}
}
throw error ;
2026-08-06 15:39:49 -04:00
}
2026-08-13 18:03:37 -04:00
let antigravityMigrationWarnings = [];
try {
const antigravityMigration = cleanupLegacyAntigravityInstall ( appliedPlan );
if ( antigravityMigration . detected && ! antigravityMigration . complete ) {
antigravityMigrationWarnings = [
2026-08-13 16:42:51 -04:00
'Legacy Antigravity migration is incomplete. ECC preserved modified, unverifiable, or unmanaged content under .agent; review and move anything you want to keep, then rerun the Antigravity install.' ,
...( Array . isArray ( antigravityMigration . warnings ) ? antigravityMigration . warnings : []),
2026-08-13 18:03:37 -04:00
];
}
} catch ( error ) {
antigravityMigrationWarnings = [
`Legacy Antigravity cleanup did not finish: ${ error . message } . Content under .agent was preserved; remove it manually or rerun the Antigravity install.` ,
];
}
2026-03-14 12:55:25 -07:00
return {
... plan ,
2026-08-06 15:39:49 -04:00
statePreview : finalState ,
2026-07-26 03:20:06 -07:00
plannedOperations : [... plan . operations ],
operations : migration . appliedOperations ,
skippedOperations : migration . skippedOperations ,
warnings : [
...( Array . isArray ( plan . warnings ) ? plan . warnings : []),
... migration . warnings ,
2026-08-13 16:42:51 -04:00
... antigravityMigrationWarnings ,
2026-07-26 03:20:06 -07:00
],
2026-03-14 12:55:25 -07:00
applied : true ,
};
}
module . exports = {
applyInstallPlan ,
2026-08-06 15:39:49 -04:00
assertSafeInstallOperation ,
2026-07-26 03:20:06 -07:00
previewInstallPlan ,
2026-03-14 12:55:25 -07:00
};