2026-03-14 12:55:25 -07:00
const fs = require ( 'fs' );
2026-07-08 17:14:33 -04:00
const { execFileSync } = require ( 'child_process' );
2026-03-28 11:28:12 +08:00
const os = require ( 'os' );
2026-03-14 12:55:25 -07:00
const path = require ( 'path' );
const { resolveInstallPlan , loadInstallManifests } = require ( './install-manifests' );
const { readInstallState , writeInstallState } = require ( './install-state' );
2026-06-18 19:54:22 -04:00
const { assertWithinTrustedRoot } = require ( './path-safety' );
2026-06-18 20:03:24 -04:00
const { createManifestInstallPlan } = require ( './install-executor' );
2026-07-26 03:20:06 -07:00
const {
prepareClaudeSkillMigration ,
removeLegacyClaudeSkillFiles ,
} = require ( './install/claude-skill-migration' );
2026-06-18 20:03:24 -04:00
const { getInstallTargetAdapter , listInstallTargetAdapters } = require ( './install-targets/registry' );
2026-07-08 17:14:33 -04:00
const OPENCODE_BUILD_ARTIFACT = path . join ( '.opencode' , 'dist' );
const OPENCODE_BUILD_SCRIPT = path . join ( 'scripts' , 'build-opencode.js' );
const OPENCODE_PLUGIN_NOT_BUILT_CODE = 'opencode-plugin-not-built' ;
2026-03-14 12:55:25 -07:00
const DEFAULT_REPO_ROOT = path . join ( __dirname , '../..' );
function readPackageVersion ( repoRoot ) {
try {
const packageJson = JSON . parse ( fs . readFileSync ( path . join ( repoRoot , 'package.json' ), 'utf8' ));
return packageJson . version || null ;
} catch ( _error ) {
return null ;
}
}
function normalizeTargets ( targets ) {
if ( ! Array . isArray ( targets ) || targets . length === 0 ) {
return listInstallTargetAdapters (). map ( adapter => adapter . target );
}
const normalizedTargets = [];
for ( const target of targets ) {
const adapter = getInstallTargetAdapter ( target );
if ( ! normalizedTargets . includes ( adapter . target )) {
normalizedTargets . push ( adapter . target );
}
}
return normalizedTargets ;
}
function compareStringArrays ( left , right ) {
const leftValues = Array . isArray ( left ) ? left : [];
const rightValues = Array . isArray ( right ) ? right : [];
if ( leftValues . length !== rightValues . length ) {
return false ;
}
return leftValues . every (( value , index ) => value === rightValues [ index ]);
}
2026-07-08 17:14:33 -04:00
function hasOpencodeBuildError ( issues ) {
return Array . isArray ( issues ) && issues . some ( issue => issue . code === OPENCODE_PLUGIN_NOT_BUILT_CODE );
}
function getOpencodeBuildValidationIssues ( context ) {
return getInstallTargetAdapter ( 'opencode' ). validate ({
homeDir : context . homeDir ,
repoRoot : context . repoRoot ,
});
}
function buildOpencodePayload ( repoRoot , buildRunner = execFileSync ) {
buildRunner ( process . execPath , [ path . join ( repoRoot , OPENCODE_BUILD_SCRIPT )], {
cwd : repoRoot ,
encoding : 'utf8' ,
stdio : [ 'ignore' , 'pipe' , 'pipe' ],
});
}
function formatBuildErrorMessage ( error ) {
const stderr = typeof error . stderr === 'string' ? error . stderr . trim () : '' ;
const stdout = typeof error . stdout === 'string' ? error . stdout . trim () : '' ;
return stderr || stdout || error . message || 'Failed to build OpenCode payload' ;
}
2026-03-14 12:55:25 -07:00
function getManagedOperations ( state ) {
2026-06-18 20:03:24 -04:00
return Array . isArray ( state && state . operations ) ? state . operations . filter ( operation => operation . ownership === 'managed' ) : [];
2026-03-14 12:55:25 -07:00
}
function resolveOperationSourcePath ( repoRoot , operation ) {
if ( operation . sourceRelativePath ) {
return path . join ( repoRoot , operation . sourceRelativePath );
}
return operation . sourcePath || null ;
}
function areFilesEqual ( leftPath , rightPath ) {
try {
const leftStat = fs . statSync ( leftPath );
const rightStat = fs . statSync ( rightPath );
if ( ! leftStat . isFile () || ! rightStat . isFile ()) {
return false ;
}
return fs . readFileSync ( leftPath ). equals ( fs . readFileSync ( rightPath ));
} catch ( _error ) {
return false ;
}
}
2026-03-15 21:47:31 -07:00
function readFileUtf8 ( filePath ) {
return fs . readFileSync ( filePath , 'utf8' );
}
function isPlainObject ( value ) {
return Boolean ( value ) && typeof value === 'object' && ! Array . isArray ( value );
}
function cloneJsonValue ( value ) {
if ( value === undefined ) {
return undefined ;
}
return JSON . parse ( JSON . stringify ( value ));
}
function parseJsonLikeValue ( value , label ) {
if ( value === undefined ) {
return undefined ;
}
if ( typeof value === 'string' ) {
try {
return JSON . parse ( value );
} catch ( error ) {
throw new Error ( `Invalid ${ label } : ${ error . message } ` );
}
}
if ( value === null || Array . isArray ( value ) || isPlainObject ( value ) || typeof value === 'number' || typeof value === 'boolean' ) {
return cloneJsonValue ( value );
}
throw new Error ( `Invalid ${ label } : expected JSON-compatible data` );
}
function getOperationTextContent ( operation ) {
2026-06-18 20:03:24 -04:00
const candidateKeys = [ 'renderedContent' , 'content' , 'managedContent' , 'expectedContent' , 'templateOutput' ];
2026-03-15 21:47:31 -07:00
for ( const key of candidateKeys ) {
if ( typeof operation [ key ] === 'string' ) {
return operation [ key ];
}
}
return null ;
}
function getOperationJsonPayload ( operation ) {
2026-06-18 20:03:24 -04:00
const candidateKeys = [ 'mergePayload' , 'managedPayload' , 'payload' , 'value' , 'expectedValue' ];
2026-03-15 21:47:31 -07:00
for ( const key of candidateKeys ) {
if ( operation [ key ] !== undefined ) {
return parseJsonLikeValue ( operation [ key ], ` ${ operation . kind } . ${ key } ` );
}
}
return undefined ;
}
function getOperationPreviousContent ( operation ) {
2026-06-18 20:03:24 -04:00
const candidateKeys = [ 'previousContent' , 'originalContent' , 'backupContent' ];
2026-03-15 21:47:31 -07:00
for ( const key of candidateKeys ) {
if ( typeof operation [ key ] === 'string' ) {
return operation [ key ];
}
}
return null ;
}
function getOperationPreviousJson ( operation ) {
2026-06-18 20:03:24 -04:00
const candidateKeys = [ 'previousValue' , 'previousJson' , 'originalValue' ];
2026-03-15 21:47:31 -07:00
for ( const key of candidateKeys ) {
if ( operation [ key ] !== undefined ) {
return parseJsonLikeValue ( operation [ key ], ` ${ operation . kind } . ${ key } ` );
}
}
return undefined ;
}
function formatJson ( value ) {
return ` ${ JSON . stringify ( value , null , 2 ) } \n` ;
}
function readJsonFile ( filePath ) {
return JSON . parse ( readFileUtf8 ( filePath ));
}
function ensureParentDir ( filePath ) {
fs . mkdirSync ( path . dirname ( filePath ), { recursive : true });
}
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 jsonContainsSubset ( actualValue , expectedValue ) {
if ( isPlainObject ( expectedValue )) {
if ( ! isPlainObject ( actualValue )) {
return false ;
}
2026-06-18 20:03:24 -04:00
return Object . entries ( expectedValue ). every (([ key , value ]) => Object . prototype . hasOwnProperty . call ( actualValue , key ) && jsonContainsSubset ( actualValue [ key ], value ));
2026-03-15 21:47:31 -07:00
}
if ( Array . isArray ( expectedValue )) {
if ( ! Array . isArray ( actualValue ) || actualValue . length !== expectedValue . length ) {
return false ;
}
return expectedValue . every (( item , index ) => jsonContainsSubset ( actualValue [ index ], item ));
}
return actualValue === expectedValue ;
}
const JSON_REMOVE_SENTINEL = Symbol ( 'json-remove' );
function deepRemoveJsonSubset ( currentValue , managedValue ) {
if ( isPlainObject ( managedValue )) {
if ( ! isPlainObject ( currentValue )) {
return currentValue ;
}
const nextValue = { ... currentValue };
for ( const [ key , value ] of Object . entries ( managedValue )) {
if ( ! Object . prototype . hasOwnProperty . call ( nextValue , key )) {
continue ;
}
if ( isPlainObject ( value )) {
const nestedValue = deepRemoveJsonSubset ( nextValue [ key ], value );
if ( nestedValue === JSON_REMOVE_SENTINEL ) {
delete nextValue [ key ];
} else {
nextValue [ key ] = nestedValue ;
}
continue ;
}
if ( Array . isArray ( value )) {
if ( Array . isArray ( nextValue [ key ]) && jsonContainsSubset ( nextValue [ key ], value )) {
delete nextValue [ key ];
}
continue ;
}
if ( nextValue [ key ] === value ) {
delete nextValue [ key ];
}
}
return Object . keys ( nextValue ). length === 0 ? JSON_REMOVE_SENTINEL : nextValue ;
}
if ( Array . isArray ( managedValue )) {
return jsonContainsSubset ( currentValue , managedValue ) ? JSON_REMOVE_SENTINEL : currentValue ;
}
return currentValue === managedValue ? JSON_REMOVE_SENTINEL : currentValue ;
}
function hydrateRecordedOperations ( repoRoot , operations ) {
return operations . map ( operation => {
if ( operation . kind !== 'copy-file' ) {
return { ... operation };
}
return {
... operation ,
2026-06-18 20:03:24 -04:00
sourcePath : resolveOperationSourcePath ( repoRoot , operation )
2026-03-15 21:47:31 -07:00
};
});
}
function buildRecordedStatePreview ( state , context , operations ) {
return {
... state ,
operations : operations . map ( operation => ({ ... operation })),
source : {
... state . source ,
repoVersion : context . packageVersion ,
2026-06-18 20:03:24 -04:00
manifestVersion : context . manifestVersion
2026-03-15 21:47:31 -07:00
},
2026-06-18 20:03:24 -04:00
lastValidatedAt : new Date (). toISOString ()
2026-03-15 21:47:31 -07:00
};
}
function shouldRepairFromRecordedOperations ( state ) {
return getManagedOperations ( state ). some ( operation => operation . kind !== 'copy-file' );
}
2026-06-18 19:54:22 -04:00
function executeRepairOperation ( repoRoot , operation , trustedRoot ) {
// 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).
assertWithinTrustedRoot ( operation . destinationPath , trustedRoot , 'repair' );
2026-03-15 21:47:31 -07:00
if ( operation . kind === 'copy-file' ) {
const sourcePath = resolveOperationSourcePath ( repoRoot , operation );
if ( ! sourcePath || ! fs . existsSync ( sourcePath )) {
throw new Error ( `Missing source file for repair: ${ sourcePath || operation . sourceRelativePath } ` );
}
ensureParentDir ( operation . destinationPath );
fs . copyFileSync ( sourcePath , operation . destinationPath );
return ;
}
if ( operation . kind === 'render-template' ) {
const renderedContent = getOperationTextContent ( operation );
if ( renderedContent === null ) {
throw new Error ( `Missing rendered content for repair: ${ operation . destinationPath } ` );
}
ensureParentDir ( operation . destinationPath );
fs . writeFileSync ( operation . destinationPath , renderedContent );
return ;
}
if ( operation . kind === 'merge-json' ) {
const payload = getOperationJsonPayload ( operation );
if ( payload === undefined ) {
throw new Error ( `Missing merge payload for repair: ${ operation . destinationPath } ` );
}
2026-06-18 20:03:24 -04:00
const currentValue = fs . existsSync ( operation . destinationPath ) ? readJsonFile ( operation . destinationPath ) : {};
2026-03-15 21:47:31 -07:00
const mergedValue = deepMergeJson ( currentValue , payload );
ensureParentDir ( operation . destinationPath );
fs . writeFileSync ( operation . destinationPath , formatJson ( mergedValue ));
return ;
}
if ( operation . kind === 'remove' ) {
if ( ! fs . existsSync ( operation . destinationPath )) {
return ;
}
fs . rmSync ( operation . destinationPath , { recursive : true , force : true });
return ;
}
throw new Error ( `Unsupported repair operation kind: ${ operation . kind } ` );
}
2026-06-18 19:54:22 -04:00
function executeUninstallOperation ( operation , trustedRoot ) {
// Confine deletes to the trusted install root (GHSA-hfpv-w6mp-5g95).
assertWithinTrustedRoot ( operation . destinationPath , trustedRoot , 'uninstall' );
2026-03-15 21:47:31 -07:00
if ( operation . kind === 'copy-file' ) {
if ( ! fs . existsSync ( operation . destinationPath )) {
return {
removedPaths : [],
2026-06-18 20:03:24 -04:00
cleanupTargets : []
2026-03-15 21:47:31 -07:00
};
}
fs . rmSync ( operation . destinationPath , { force : true });
return {
removedPaths : [ operation . destinationPath ],
2026-06-18 20:03:24 -04:00
cleanupTargets : [ operation . destinationPath ]
2026-03-15 21:47:31 -07:00
};
}
if ( operation . kind === 'render-template' ) {
const previousContent = getOperationPreviousContent ( operation );
if ( previousContent !== null ) {
ensureParentDir ( operation . destinationPath );
fs . writeFileSync ( operation . destinationPath , previousContent );
return {
removedPaths : [],
2026-06-18 20:03:24 -04:00
cleanupTargets : []
2026-03-15 21:47:31 -07:00
};
}
const previousJson = getOperationPreviousJson ( operation );
if ( previousJson !== undefined ) {
ensureParentDir ( operation . destinationPath );
fs . writeFileSync ( operation . destinationPath , formatJson ( previousJson ));
return {
removedPaths : [],
2026-06-18 20:03:24 -04:00
cleanupTargets : []
2026-03-15 21:47:31 -07:00
};
}
if ( ! fs . existsSync ( operation . destinationPath )) {
return {
removedPaths : [],
2026-06-18 20:03:24 -04:00
cleanupTargets : []
2026-03-15 21:47:31 -07:00
};
}
fs . rmSync ( operation . destinationPath , { force : true });
return {
removedPaths : [ operation . destinationPath ],
2026-06-18 20:03:24 -04:00
cleanupTargets : [ operation . destinationPath ]
2026-03-15 21:47:31 -07:00
};
}
if ( operation . kind === 'merge-json' ) {
const previousContent = getOperationPreviousContent ( operation );
if ( previousContent !== null ) {
ensureParentDir ( operation . destinationPath );
fs . writeFileSync ( operation . destinationPath , previousContent );
return {
removedPaths : [],
2026-06-18 20:03:24 -04:00
cleanupTargets : []
2026-03-15 21:47:31 -07:00
};
}
const previousJson = getOperationPreviousJson ( operation );
if ( previousJson !== undefined ) {
ensureParentDir ( operation . destinationPath );
fs . writeFileSync ( operation . destinationPath , formatJson ( previousJson ));
return {
removedPaths : [],
2026-06-18 20:03:24 -04:00
cleanupTargets : []
2026-03-15 21:47:31 -07:00
};
}
if ( ! fs . existsSync ( operation . destinationPath )) {
return {
removedPaths : [],
2026-06-18 20:03:24 -04:00
cleanupTargets : []
2026-03-15 21:47:31 -07:00
};
}
const payload = getOperationJsonPayload ( operation );
if ( payload === undefined ) {
throw new Error ( `Missing merge payload for uninstall: ${ operation . destinationPath } ` );
}
const currentValue = readJsonFile ( operation . destinationPath );
const nextValue = deepRemoveJsonSubset ( currentValue , payload );
if ( nextValue === JSON_REMOVE_SENTINEL ) {
fs . rmSync ( operation . destinationPath , { force : true });
return {
removedPaths : [ operation . destinationPath ],
2026-06-18 20:03:24 -04:00
cleanupTargets : [ operation . destinationPath ]
2026-03-15 21:47:31 -07:00
};
}
ensureParentDir ( operation . destinationPath );
fs . writeFileSync ( operation . destinationPath , formatJson ( nextValue ));
return {
removedPaths : [],
2026-06-18 20:03:24 -04:00
cleanupTargets : []
2026-03-15 21:47:31 -07:00
};
}
if ( operation . kind === 'remove' ) {
const previousContent = getOperationPreviousContent ( operation );
if ( previousContent !== null ) {
ensureParentDir ( operation . destinationPath );
fs . writeFileSync ( operation . destinationPath , previousContent );
return {
removedPaths : [],
2026-06-18 20:03:24 -04:00
cleanupTargets : []
2026-03-15 21:47:31 -07:00
};
}
const previousJson = getOperationPreviousJson ( operation );
if ( previousJson !== undefined ) {
ensureParentDir ( operation . destinationPath );
fs . writeFileSync ( operation . destinationPath , formatJson ( previousJson ));
return {
removedPaths : [],
2026-06-18 20:03:24 -04:00
cleanupTargets : []
2026-03-15 21:47:31 -07:00
};
}
return {
removedPaths : [],
2026-06-18 20:03:24 -04:00
cleanupTargets : []
2026-03-15 21:47:31 -07:00
};
}
throw new Error ( `Unsupported uninstall operation kind: ${ operation . kind } ` );
}
2026-03-14 12:55:25 -07:00
function inspectManagedOperation ( repoRoot , operation ) {
const destinationPath = operation . destinationPath ;
if ( ! destinationPath ) {
return {
status : 'invalid-destination' ,
2026-06-18 20:03:24 -04:00
operation
2026-03-14 12:55:25 -07:00
};
}
2026-03-15 21:47:31 -07:00
if ( operation . kind === 'remove' ) {
if ( fs . existsSync ( destinationPath )) {
return {
status : 'drifted' ,
operation ,
2026-06-18 20:03:24 -04:00
destinationPath
2026-03-15 21:47:31 -07:00
};
}
return {
status : 'ok' ,
operation ,
2026-06-18 20:03:24 -04:00
destinationPath
2026-03-15 21:47:31 -07:00
};
}
2026-03-14 12:55:25 -07:00
if ( ! fs . existsSync ( destinationPath )) {
return {
status : 'missing' ,
operation ,
2026-06-18 20:03:24 -04:00
destinationPath
2026-03-14 12:55:25 -07:00
};
}
2026-03-15 21:47:31 -07:00
if ( operation . kind === 'copy-file' ) {
const sourcePath = resolveOperationSourcePath ( repoRoot , operation );
if ( ! sourcePath || ! fs . existsSync ( sourcePath )) {
return {
status : 'missing-source' ,
operation ,
destinationPath ,
2026-06-18 20:03:24 -04:00
sourcePath
2026-03-15 21:47:31 -07:00
};
}
if ( ! areFilesEqual ( sourcePath , destinationPath )) {
return {
status : 'drifted' ,
operation ,
destinationPath ,
2026-06-18 20:03:24 -04:00
sourcePath
2026-03-15 21:47:31 -07:00
};
}
2026-03-14 12:55:25 -07:00
return {
2026-03-15 21:47:31 -07:00
status : 'ok' ,
2026-03-14 12:55:25 -07:00
operation ,
destinationPath ,
2026-06-18 20:03:24 -04:00
sourcePath
2026-03-14 12:55:25 -07:00
};
}
2026-03-15 21:47:31 -07:00
if ( operation . kind === 'render-template' ) {
const renderedContent = getOperationTextContent ( operation );
if ( renderedContent === null ) {
return {
status : 'unverified' ,
operation ,
2026-06-18 20:03:24 -04:00
destinationPath
2026-03-15 21:47:31 -07:00
};
}
if ( readFileUtf8 ( destinationPath ) !== renderedContent ) {
return {
status : 'drifted' ,
operation ,
2026-06-18 20:03:24 -04:00
destinationPath
2026-03-15 21:47:31 -07:00
};
}
2026-03-14 12:55:25 -07:00
return {
2026-03-15 21:47:31 -07:00
status : 'ok' ,
operation ,
2026-06-18 20:03:24 -04:00
destinationPath
2026-03-15 21:47:31 -07:00
};
}
if ( operation . kind === 'merge-json' ) {
const payload = getOperationJsonPayload ( operation );
if ( payload === undefined ) {
return {
status : 'unverified' ,
operation ,
2026-06-18 20:03:24 -04:00
destinationPath
2026-03-15 21:47:31 -07:00
};
}
try {
const currentValue = readJsonFile ( destinationPath );
if ( ! jsonContainsSubset ( currentValue , payload )) {
return {
status : 'drifted' ,
operation ,
2026-06-18 20:03:24 -04:00
destinationPath
2026-03-15 21:47:31 -07:00
};
}
} catch ( _error ) {
return {
status : 'drifted' ,
operation ,
2026-06-18 20:03:24 -04:00
destinationPath
2026-03-15 21:47:31 -07:00
};
}
return {
status : 'ok' ,
2026-03-14 12:55:25 -07:00
operation ,
2026-06-18 20:03:24 -04:00
destinationPath
2026-03-14 12:55:25 -07:00
};
}
return {
2026-03-15 21:47:31 -07:00
status : 'unverified' ,
2026-03-14 12:55:25 -07:00
operation ,
2026-06-18 20:03:24 -04:00
destinationPath
2026-03-14 12:55:25 -07:00
};
}
function summarizeManagedOperationHealth ( repoRoot , operations ) {
2026-06-18 20:03:24 -04:00
return operations . reduce (
( summary , operation ) => {
const inspection = inspectManagedOperation ( repoRoot , operation );
if ( inspection . status === 'missing' ) {
summary . missing . push ( inspection );
} else if ( inspection . status === 'drifted' ) {
summary . drifted . push ( inspection );
} else if ( inspection . status === 'missing-source' ) {
summary . missingSource . push ( inspection );
} else if ( inspection . status === 'unverified' || inspection . status === 'invalid-destination' ) {
summary . unverified . push ( inspection );
}
return summary ;
},
{
missing : [],
drifted : [],
missingSource : [],
unverified : []
2026-03-14 12:55:25 -07:00
}
2026-06-18 20:03:24 -04:00
);
2026-03-14 12:55:25 -07:00
}
function buildDiscoveryRecord ( adapter , context ) {
const installTargetInput = {
homeDir : context . homeDir ,
projectRoot : context . projectRoot ,
2026-06-18 20:03:24 -04:00
repoRoot : context . projectRoot
2026-03-14 12:55:25 -07:00
};
const targetRoot = adapter . resolveRoot ( installTargetInput );
const installStatePath = adapter . getInstallStatePath ( installTargetInput );
const exists = fs . existsSync ( installStatePath );
if ( ! exists ) {
return {
adapter : {
id : adapter . id ,
target : adapter . target ,
2026-06-18 20:03:24 -04:00
kind : adapter . kind
2026-03-14 12:55:25 -07:00
},
targetRoot ,
installStatePath ,
exists : false ,
state : null ,
2026-06-18 20:03:24 -04:00
error : null
2026-03-14 12:55:25 -07:00
};
}
try {
const state = readInstallState ( installStatePath );
return {
adapter : {
id : adapter . id ,
target : adapter . target ,
2026-06-18 20:03:24 -04:00
kind : adapter . kind
2026-03-14 12:55:25 -07:00
},
targetRoot ,
installStatePath ,
exists : true ,
state ,
2026-06-18 20:03:24 -04:00
error : null
2026-03-14 12:55:25 -07:00
};
} catch ( error ) {
return {
adapter : {
id : adapter . id ,
target : adapter . target ,
2026-06-18 20:03:24 -04:00
kind : adapter . kind
2026-03-14 12:55:25 -07:00
},
targetRoot ,
installStatePath ,
exists : true ,
state : null ,
2026-06-18 20:03:24 -04:00
error : error . message
2026-03-14 12:55:25 -07:00
};
}
}
function discoverInstalledStates ( options = {}) {
const context = {
2026-03-28 11:28:12 +08:00
homeDir : options . homeDir || process . env . HOME || os . homedir (),
2026-06-18 20:03:24 -04:00
projectRoot : options . projectRoot || process . cwd ()
2026-03-14 12:55:25 -07:00
};
const targets = normalizeTargets ( options . targets );
return targets . map ( target => {
const adapter = getInstallTargetAdapter ( target );
return buildDiscoveryRecord ( adapter , context );
});
}
function buildIssue ( severity , code , message , extra = {}) {
return {
severity ,
code ,
message ,
2026-06-18 20:03:24 -04:00
... extra
2026-03-14 12:55:25 -07:00
};
}
function determineStatus ( issues ) {
if ( issues . some ( issue => issue . severity === 'error' )) {
return 'error' ;
}
if ( issues . some ( issue => issue . severity === 'warning' )) {
return 'warning' ;
}
return 'ok' ;
}
function analyzeRecord ( record , context ) {
const issues = [];
if ( record . error ) {
issues . push ( buildIssue ( 'error' , 'invalid-install-state' , record . error ));
return {
... record ,
status : determineStatus ( issues ),
2026-06-18 20:03:24 -04:00
issues
2026-03-14 12:55:25 -07:00
};
}
const state = record . state ;
if ( ! state ) {
return {
... record ,
status : 'missing' ,
2026-06-18 20:03:24 -04:00
issues
2026-03-14 12:55:25 -07:00
};
}
if ( ! fs . existsSync ( state . target . root )) {
2026-06-18 20:03:24 -04:00
issues . push ( buildIssue ( 'error' , 'missing-target-root' , `Target root does not exist: ${ state . target . root } ` ));
2026-03-14 12:55:25 -07:00
}
if ( state . target . root !== record . targetRoot ) {
2026-06-18 20:03:24 -04:00
issues . push (
buildIssue ( 'warning' , 'target-root-mismatch' , `Recorded target root differs from current target root ( ${ record . targetRoot } )` , {
2026-03-14 12:55:25 -07:00
recordedTargetRoot : state . target . root ,
2026-06-18 20:03:24 -04:00
currentTargetRoot : record . targetRoot
})
);
2026-03-14 12:55:25 -07:00
}
if ( state . target . installStatePath !== record . installStatePath ) {
2026-06-18 20:03:24 -04:00
issues . push (
buildIssue ( 'warning' , 'install-state-path-mismatch' , `Recorded install-state path differs from current path ( ${ record . installStatePath } )` , {
2026-03-14 12:55:25 -07:00
recordedInstallStatePath : state . target . installStatePath ,
2026-06-18 20:03:24 -04:00
currentInstallStatePath : record . installStatePath
})
);
2026-03-14 12:55:25 -07:00
}
const managedOperations = getManagedOperations ( state );
const operationHealth = summarizeManagedOperationHealth ( context . repoRoot , managedOperations );
const missingManagedOperations = operationHealth . missing ;
if ( missingManagedOperations . length > 0 ) {
2026-06-18 20:03:24 -04:00
issues . push (
buildIssue ( 'error' , 'missing-managed-files' , ` ${ missingManagedOperations . length } managed file(s) are missing` , {
paths : missingManagedOperations . map ( entry => entry . destinationPath )
})
);
2026-03-14 12:55:25 -07:00
}
if ( operationHealth . drifted . length > 0 ) {
2026-06-18 20:03:24 -04:00
issues . push (
buildIssue ( 'warning' , 'drifted-managed-files' , ` ${ operationHealth . drifted . length } managed file(s) differ from the source repo` , {
paths : operationHealth . drifted . map ( entry => entry . destinationPath )
})
);
2026-03-14 12:55:25 -07:00
}
if ( operationHealth . missingSource . length > 0 ) {
2026-06-18 20:03:24 -04:00
issues . push (
buildIssue ( 'error' , 'missing-source-files' , ` ${ operationHealth . missingSource . length } source file(s) referenced by install-state are missing` , {
paths : operationHealth . missingSource . map ( entry => entry . sourcePath ). filter ( Boolean )
})
);
2026-03-14 12:55:25 -07:00
}
if ( operationHealth . unverified . length > 0 ) {
2026-06-18 20:03:24 -04:00
issues . push (
buildIssue ( 'warning' , 'unverified-managed-operations' , ` ${ operationHealth . unverified . length } managed operation(s) could not be content-verified` , {
paths : operationHealth . unverified . map ( entry => entry . destinationPath ). filter ( Boolean )
})
);
2026-03-14 12:55:25 -07:00
}
if ( state . source . manifestVersion !== context . manifestVersion ) {
2026-06-18 20:03:24 -04:00
issues . push ( buildIssue ( 'warning' , 'manifest-version-mismatch' , `Recorded manifest version ${ state . source . manifestVersion } differs from current manifest version ${ context . manifestVersion } ` ));
2026-03-14 12:55:25 -07:00
}
2026-06-18 20:03:24 -04:00
if ( context . packageVersion && state . source . repoVersion && state . source . repoVersion !== context . packageVersion ) {
issues . push ( buildIssue ( 'warning' , 'repo-version-mismatch' , `Recorded repo version ${ state . source . repoVersion } differs from current repo version ${ context . packageVersion } ` ));
2026-03-14 12:55:25 -07:00
}
if ( ! state . request . legacyMode ) {
try {
const desiredPlan = resolveInstallPlan ({
repoRoot : context . repoRoot ,
projectRoot : context . projectRoot ,
homeDir : context . homeDir ,
target : record . adapter . target ,
profileId : state . request . profile || null ,
moduleIds : state . request . modules || [],
includeComponentIds : state . request . includeComponents || [],
2026-06-18 20:03:24 -04:00
excludeComponentIds : state . request . excludeComponents || []
2026-03-14 12:55:25 -07:00
});
2026-06-18 20:03:24 -04:00
if ( ! compareStringArrays ( desiredPlan . selectedModuleIds , state . resolution . selectedModules ) || ! compareStringArrays ( desiredPlan . skippedModuleIds , state . resolution . skippedModules )) {
issues . push (
buildIssue ( 'warning' , 'resolution-drift' , 'Current manifest resolution differs from recorded install-state' , {
2026-03-14 12:55:25 -07:00
expectedSelectedModules : desiredPlan . selectedModuleIds ,
recordedSelectedModules : state . resolution . selectedModules ,
expectedSkippedModules : desiredPlan . skippedModuleIds ,
2026-06-18 20:03:24 -04:00
recordedSkippedModules : state . resolution . skippedModules
})
);
2026-03-14 12:55:25 -07:00
}
} catch ( error ) {
2026-06-18 20:03:24 -04:00
issues . push ( buildIssue ( 'error' , 'resolution-unavailable' , error . message ));
2026-03-14 12:55:25 -07:00
}
}
return {
... record ,
status : determineStatus ( issues ),
2026-06-18 20:03:24 -04:00
issues
2026-03-14 12:55:25 -07:00
};
}
function buildDoctorReport ( options = {}) {
const repoRoot = options . repoRoot || DEFAULT_REPO_ROOT ;
const manifests = loadInstallManifests ({ repoRoot });
const records = discoverInstalledStates ({
homeDir : options . homeDir ,
projectRoot : options . projectRoot ,
2026-06-18 20:03:24 -04:00
targets : options . targets
2026-03-14 12:55:25 -07:00
}). filter ( record => record . exists );
const context = {
repoRoot ,
2026-03-28 11:28:12 +08:00
homeDir : options . homeDir || process . env . HOME || os . homedir (),
2026-03-14 12:55:25 -07:00
projectRoot : options . projectRoot || process . cwd (),
manifestVersion : manifests . modulesVersion ,
2026-06-18 20:03:24 -04:00
packageVersion : readPackageVersion ( repoRoot )
2026-03-14 12:55:25 -07:00
};
const results = records . map ( record => analyzeRecord ( record , context ));
2026-06-18 20:03:24 -04:00
const summary = results . reduce (
( accumulator , result ) => {
const errorCount = result . issues . filter ( issue => issue . severity === 'error' ). length ;
const warningCount = result . issues . filter ( issue => issue . severity === 'warning' ). length ;
2026-03-14 12:55:25 -07:00
2026-06-18 20:03:24 -04:00
return {
checkedCount : accumulator . checkedCount + 1 ,
okCount : accumulator . okCount + ( result . status === 'ok' ? 1 : 0 ),
errorCount : accumulator . errorCount + errorCount ,
warningCount : accumulator . warningCount + warningCount
};
},
{
checkedCount : 0 ,
okCount : 0 ,
errorCount : 0 ,
warningCount : 0
}
);
2026-03-14 12:55:25 -07:00
return {
generatedAt : new Date (). toISOString (),
packageVersion : context . packageVersion ,
manifestVersion : context . manifestVersion ,
results ,
2026-06-18 20:03:24 -04:00
summary
2026-03-14 12:55:25 -07:00
};
}
2026-07-08 17:14:33 -04:00
function createRepairPlanFromRecord ( record , context , options = {}) {
2026-03-14 12:55:25 -07:00
const state = record . state ;
if ( ! state ) {
throw new Error ( 'No install-state available for repair' );
}
2026-03-15 21:47:31 -07:00
if ( state . request . legacyMode || shouldRepairFromRecordedOperations ( state )) {
const operations = hydrateRecordedOperations ( context . repoRoot , getManagedOperations ( state ));
const statePreview = buildRecordedStatePreview ( state , context , operations );
2026-03-14 12:55:25 -07:00
return {
2026-03-15 21:47:31 -07:00
mode : state . request . legacyMode ? 'legacy' : 'recorded' ,
2026-03-14 12:55:25 -07:00
target : record . adapter . target ,
adapter : record . adapter ,
targetRoot : state . target . root ,
installRoot : state . target . root ,
installStatePath : state . target . installStatePath ,
warnings : [],
2026-06-18 20:03:24 -04:00
languages : Array . isArray ( state . request . legacyLanguages ) ? [... state . request . legacyLanguages ] : [],
2026-03-14 12:55:25 -07:00
operations ,
2026-06-18 20:03:24 -04:00
statePreview
2026-03-14 12:55:25 -07:00
};
}
const desiredPlan = createManifestInstallPlan ({
sourceRoot : context . repoRoot ,
target : record . adapter . target ,
profileId : state . request . profile || null ,
moduleIds : state . request . modules || [],
includeComponentIds : state . request . includeComponents || [],
excludeComponentIds : state . request . excludeComponents || [],
projectRoot : context . projectRoot ,
2026-07-08 17:14:33 -04:00
homeDir : context . homeDir ,
exemptValidationCodes : options . exemptValidationCodes || [],
2026-03-14 12:55:25 -07:00
});
return {
... desiredPlan ,
statePreview : {
... desiredPlan . statePreview ,
installedAt : state . installedAt ,
2026-06-18 20:03:24 -04:00
lastValidatedAt : new Date (). toISOString ()
}
2026-03-14 12:55:25 -07:00
};
}
2026-07-26 03:20:06 -07:00
function prepareRepairMigration ( plan ) {
const migration = prepareClaudeSkillMigration ( plan );
return {
migration ,
plan : {
... plan ,
operations : migration . finalState . operations ,
statePreview : migration . finalState ,
warnings : [
...( Array . isArray ( plan . warnings ) ? plan . warnings : []),
... migration . warnings ,
],
},
};
}
2026-03-14 12:55:25 -07:00
function repairInstalledStates ( options = {}) {
const repoRoot = options . repoRoot || DEFAULT_REPO_ROOT ;
const manifests = loadInstallManifests ({ repoRoot });
const context = {
repoRoot ,
2026-03-28 11:28:12 +08:00
homeDir : options . homeDir || process . env . HOME || os . homedir (),
2026-03-14 12:55:25 -07:00
projectRoot : options . projectRoot || process . cwd (),
manifestVersion : manifests . modulesVersion ,
2026-06-18 20:03:24 -04:00
packageVersion : readPackageVersion ( repoRoot )
2026-03-14 12:55:25 -07:00
};
2026-07-08 17:14:33 -04:00
const buildOpencodeRunner = typeof options . buildOpencodePayload === 'function'
? options . buildOpencodePayload
: buildOpencodePayload ;
2026-03-14 12:55:25 -07:00
const records = discoverInstalledStates ({
homeDir : context . homeDir ,
projectRoot : context . projectRoot ,
2026-06-18 20:03:24 -04:00
targets : options . targets
2026-03-14 12:55:25 -07:00
}). filter ( record => record . exists );
const results = records . map ( record => {
if ( record . error ) {
return {
adapter : record . adapter ,
status : 'error' ,
installStatePath : record . installStatePath ,
repairedPaths : [],
plannedRepairs : [],
2026-06-18 20:03:24 -04:00
error : record . error
2026-03-14 12:55:25 -07:00
};
}
try {
2026-07-08 17:14:33 -04:00
const needsOpencodeBuild = record . adapter . target === 'opencode'
&& hasOpencodeBuildError ( getOpencodeBuildValidationIssues ( context ));
const opencodeBuildRepairPath = path . join ( context . repoRoot , OPENCODE_BUILD_ARTIFACT );
if ( needsOpencodeBuild && options . dryRun ) {
2026-07-26 03:20:06 -07:00
const rawPlan = createRepairPlanFromRecord ( record , context , {
2026-07-08 17:14:33 -04:00
exemptValidationCodes : [ OPENCODE_PLUGIN_NOT_BUILT_CODE ],
});
2026-07-26 03:20:06 -07:00
const { plan : desiredPlan } = prepareRepairMigration ( rawPlan );
2026-07-08 17:14:33 -04:00
const operationHealth = summarizeManagedOperationHealth ( context . repoRoot , desiredPlan . operations );
const repairOperations = [... operationHealth . missing . map ( entry => ({ ... entry . operation })), ... operationHealth . drifted . map ( entry => ({ ... entry . operation }))];
const plannedRepairs = [ opencodeBuildRepairPath , ... repairOperations . map ( operation => operation . destinationPath )];
return {
adapter : record . adapter ,
status : 'planned' ,
installStatePath : record . installStatePath ,
repairedPaths : [],
plannedRepairs ,
stateRefreshed : false ,
2026-07-26 03:20:06 -07:00
warnings : desiredPlan . warnings ,
2026-07-08 17:14:33 -04:00
error : null
};
}
if ( needsOpencodeBuild ) {
try {
buildOpencodeRunner ( context . repoRoot );
} catch ( error ) {
return {
adapter : record . adapter ,
status : 'error' ,
installStatePath : record . installStatePath ,
repairedPaths : [],
plannedRepairs : [],
error : formatBuildErrorMessage ( error )
};
}
}
2026-07-26 03:20:06 -07:00
const rawPlan = createRepairPlanFromRecord ( record , context );
const {
migration ,
plan : desiredPlan ,
} = prepareRepairMigration ( rawPlan );
2026-03-14 12:55:25 -07:00
const operationHealth = summarizeManagedOperationHealth ( context . repoRoot , desiredPlan . operations );
if ( operationHealth . missingSource . length > 0 ) {
return {
adapter : record . adapter ,
status : 'error' ,
installStatePath : record . installStatePath ,
repairedPaths : [],
plannedRepairs : [],
2026-07-26 03:20:06 -07:00
warnings : desiredPlan . warnings ,
2026-06-18 20:03:24 -04:00
error : `Missing source file(s): ${ operationHealth . missingSource . map ( entry => entry . sourcePath ). join ( ', ' ) } `
2026-03-14 12:55:25 -07:00
};
}
2026-06-18 20:03:24 -04:00
const repairOperations = [... operationHealth . missing . map ( entry => ({ ... entry . operation })), ... operationHealth . drifted . map ( entry => ({ ... entry . operation }))];
2026-07-26 03:20:06 -07:00
const legacyMigrationPaths = migration . legacyOperationsToRemove . map (
operation => operation . destinationPath
);
const plannedRepairs = [... new Set ([
...( needsOpencodeBuild ? [ opencodeBuildRepairPath ] : []),
... repairOperations . map ( operation => operation . destinationPath ),
... legacyMigrationPaths ,
])];
2026-03-14 12:55:25 -07:00
if ( options . dryRun ) {
return {
adapter : record . adapter ,
status : plannedRepairs . length > 0 ? 'planned' : 'ok' ,
installStatePath : record . installStatePath ,
repairedPaths : [],
plannedRepairs ,
stateRefreshed : plannedRepairs . length === 0 ,
2026-07-26 03:20:06 -07:00
warnings : desiredPlan . warnings ,
2026-06-18 20:03:24 -04:00
error : null
2026-03-14 12:55:25 -07:00
};
}
2026-07-26 03:20:06 -07:00
const hasLegacyMigration = migration . legacyOperationsToRemove . length > 0 ;
if ( migration . requiresBridgeState && ( repairOperations . length > 0 || hasLegacyMigration )) {
writeInstallState ( desiredPlan . installStatePath , migration . bridgeState );
}
2026-03-14 12:55:25 -07:00
if ( repairOperations . length > 0 ) {
2026-03-15 21:47:31 -07:00
for ( const operation of repairOperations ) {
2026-06-18 19:54:22 -04:00
executeRepairOperation ( context . repoRoot , operation , record . targetRoot );
2026-03-15 21:47:31 -07:00
}
2026-03-14 12:55:25 -07:00
}
2026-07-26 03:20:06 -07:00
if ( hasLegacyMigration ) {
removeLegacyClaudeSkillFiles ( migration , desiredPlan . targetRoot );
}
writeInstallState ( desiredPlan . installStatePath , desiredPlan . statePreview );
2026-03-14 12:55:25 -07:00
return {
adapter : record . adapter ,
2026-07-26 03:20:06 -07:00
status : ( repairOperations . length > 0 || needsOpencodeBuild || hasLegacyMigration )
? 'repaired'
: 'ok' ,
2026-03-14 12:55:25 -07:00
installStatePath : record . installStatePath ,
repairedPaths : plannedRepairs ,
plannedRepairs : [],
stateRefreshed : true ,
2026-07-26 03:20:06 -07:00
warnings : desiredPlan . warnings ,
2026-06-18 20:03:24 -04:00
error : null
2026-03-14 12:55:25 -07:00
};
} catch ( error ) {
return {
adapter : record . adapter ,
status : 'error' ,
installStatePath : record . installStatePath ,
repairedPaths : [],
plannedRepairs : [],
2026-06-18 20:03:24 -04:00
error : error . message
2026-03-14 12:55:25 -07:00
};
}
});
2026-06-18 20:03:24 -04:00
const summary = results . reduce (
( accumulator , result ) => ({
checkedCount : accumulator . checkedCount + 1 ,
repairedCount : accumulator . repairedCount + ( result . status === 'repaired' ? 1 : 0 ),
plannedRepairCount : accumulator . plannedRepairCount + ( result . status === 'planned' ? 1 : 0 ),
errorCount : accumulator . errorCount + ( result . status === 'error' ? 1 : 0 )
}),
{
checkedCount : 0 ,
repairedCount : 0 ,
plannedRepairCount : 0 ,
errorCount : 0
}
);
2026-03-14 12:55:25 -07:00
return {
dryRun : Boolean ( options . dryRun ),
generatedAt : new Date (). toISOString (),
results ,
2026-06-18 20:03:24 -04:00
summary
2026-03-14 12:55:25 -07:00
};
}
function cleanupEmptyParentDirs ( filePath , stopAt ) {
let currentPath = path . dirname ( filePath );
const normalizedStopAt = path . resolve ( stopAt );
2026-06-18 20:03:24 -04:00
while ( currentPath && path . resolve ( currentPath ). startsWith ( normalizedStopAt ) && path . resolve ( currentPath ) !== normalizedStopAt ) {
2026-03-14 12:55:25 -07:00
if ( ! fs . existsSync ( currentPath )) {
currentPath = path . dirname ( currentPath );
continue ;
}
const stat = fs . lstatSync ( currentPath );
if ( ! stat . isDirectory () || fs . readdirSync ( currentPath ). length > 0 ) {
break ;
}
fs . rmdirSync ( currentPath );
currentPath = path . dirname ( currentPath );
}
}
function uninstallInstalledStates ( options = {}) {
const records = discoverInstalledStates ({
homeDir : options . homeDir ,
projectRoot : options . projectRoot ,
2026-06-18 20:03:24 -04:00
targets : options . targets
2026-03-14 12:55:25 -07:00
}). filter ( record => record . exists );
const results = records . map ( record => {
if ( record . error || ! record . state ) {
return {
adapter : record . adapter ,
status : 'error' ,
installStatePath : record . installStatePath ,
removedPaths : [],
plannedRemovals : [],
2026-06-18 20:03:24 -04:00
error : record . error || 'No valid install-state available'
2026-03-14 12:55:25 -07:00
};
}
const state = record . state ;
2026-06-18 20:03:24 -04:00
const plannedRemovals = Array . from ( new Set ([... getManagedOperations ( state ). map ( operation => operation . destinationPath ), state . target . installStatePath ]));
2026-03-14 12:55:25 -07:00
if ( options . dryRun ) {
return {
adapter : record . adapter ,
status : 'planned' ,
installStatePath : record . installStatePath ,
removedPaths : [],
plannedRemovals ,
2026-06-18 20:03:24 -04:00
error : null
2026-03-14 12:55:25 -07:00
};
}
try {
const removedPaths = [];
const cleanupTargets = [];
2026-03-15 21:47:31 -07:00
const operations = getManagedOperations ( state );
2026-03-14 12:55:25 -07:00
2026-03-15 21:47:31 -07:00
for ( const operation of operations ) {
2026-06-18 19:54:22 -04:00
const outcome = executeUninstallOperation ( operation , record . targetRoot );
2026-03-15 21:47:31 -07:00
removedPaths . push (... outcome . removedPaths );
cleanupTargets . push (... outcome . cleanupTargets );
2026-03-14 12:55:25 -07:00
}
if ( fs . existsSync ( state . target . installStatePath )) {
2026-06-18 19:54:22 -04:00
assertWithinTrustedRoot ( state . target . installStatePath , record . targetRoot , 'uninstall' );
2026-03-14 12:55:25 -07:00
fs . rmSync ( state . target . installStatePath , { force : true });
removedPaths . push ( state . target . installStatePath );
cleanupTargets . push ( state . target . installStatePath );
}
for ( const cleanupTarget of cleanupTargets ) {
cleanupEmptyParentDirs ( cleanupTarget , state . target . root );
}
return {
adapter : record . adapter ,
status : 'uninstalled' ,
installStatePath : record . installStatePath ,
removedPaths ,
plannedRemovals : [],
2026-06-18 20:03:24 -04:00
error : null
2026-03-14 12:55:25 -07:00
};
} catch ( error ) {
return {
adapter : record . adapter ,
status : 'error' ,
installStatePath : record . installStatePath ,
removedPaths : [],
plannedRemovals ,
2026-06-18 20:03:24 -04:00
error : error . message
2026-03-14 12:55:25 -07:00
};
}
});
2026-06-18 20:03:24 -04:00
const summary = results . reduce (
( accumulator , result ) => ({
checkedCount : accumulator . checkedCount + 1 ,
uninstalledCount : accumulator . uninstalledCount + ( result . status === 'uninstalled' ? 1 : 0 ),
plannedRemovalCount : accumulator . plannedRemovalCount + ( result . status === 'planned' ? 1 : 0 ),
errorCount : accumulator . errorCount + ( result . status === 'error' ? 1 : 0 )
}),
{
checkedCount : 0 ,
uninstalledCount : 0 ,
plannedRemovalCount : 0 ,
errorCount : 0
}
);
2026-03-14 12:55:25 -07:00
return {
dryRun : Boolean ( options . dryRun ),
generatedAt : new Date (). toISOString (),
results ,
2026-06-18 20:03:24 -04:00
summary
2026-03-14 12:55:25 -07:00
};
}
module . exports = {
DEFAULT_REPO_ROOT ,
buildDoctorReport ,
discoverInstalledStates ,
normalizeTargets ,
repairInstalledStates ,
2026-06-18 20:03:24 -04:00
uninstallInstalledStates
2026-03-14 12:55:25 -07:00
};