mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-29 13:05:18 +02:00
Preserve the contributor change and current installer contracts. Make automatically discovered plugin entrypoints inert without explicit runtime consent, verify managed ownership before deactivation, and retain prior consent on incomplete migration.
Source-PR: https://github.com/affaan-m/ECC/pull/3008
Source-Head: f8c0c2c182
360 lines
12 KiB
JavaScript
360 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { execFileSync } = require('child_process');
|
|
|
|
const { resolveInstallPlan } = require('../install-manifests');
|
|
const { getInstallTargetAdapter } = require('../install-targets/registry');
|
|
const { resolveInvocationEnvironment } = require('../invocation-environment');
|
|
const { disableUnselectedOpenCodeHooks } = require('./hook-consent');
|
|
const { readHooksConfig } = require('../hooks-config');
|
|
const {
|
|
materializeManagedHooks,
|
|
} = require('./claude-settings');
|
|
|
|
const EXCLUDED_GENERATED_SOURCE_SUFFIXES = ['/ecc-install-state.json', '/ecc/install-state.json'];
|
|
const IGNORED_DIRECTORY_NAMES = new Set([
|
|
'node_modules',
|
|
'.git',
|
|
'__pycache__',
|
|
'.pytest_cache',
|
|
]);
|
|
const IGNORED_FILE_EXTENSIONS = new Set(['.pyc', '.pyo', '.pyd']);
|
|
|
|
function getSourceRoot() {
|
|
return path.join(__dirname, '../../..');
|
|
}
|
|
|
|
function getPackageVersion(sourceRoot) {
|
|
try {
|
|
const packageJson = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'package.json'), 'utf8'));
|
|
return packageJson.version || null;
|
|
} catch (_error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getManifestVersion(sourceRoot) {
|
|
try {
|
|
const modulesManifest = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'manifests', 'install-modules.json'), 'utf8'));
|
|
return modulesManifest.version || 1;
|
|
} catch (_error) {
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
function getRepoCommit(sourceRoot) {
|
|
try {
|
|
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
cwd: sourceRoot,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
timeout: 5000
|
|
}).trim();
|
|
} catch (_error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function listFilesRecursive(dirPath) {
|
|
if (!fs.existsSync(dirPath)) {
|
|
return [];
|
|
}
|
|
|
|
const files = [];
|
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
const absolutePath = path.join(dirPath, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (IGNORED_DIRECTORY_NAMES.has(entry.name)) {
|
|
continue;
|
|
}
|
|
const childFiles = listFilesRecursive(absolutePath);
|
|
for (const childFile of childFiles) {
|
|
files.push(path.join(entry.name, childFile));
|
|
}
|
|
} else if (entry.isFile()) {
|
|
if (IGNORED_FILE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
|
continue;
|
|
}
|
|
files.push(entry.name);
|
|
}
|
|
}
|
|
|
|
return files.sort();
|
|
}
|
|
|
|
function isGeneratedRuntimeSourcePath(sourceRelativePath) {
|
|
const normalizedPath = String(sourceRelativePath || '').replace(/\\/g, '/');
|
|
return EXCLUDED_GENERATED_SOURCE_SUFFIXES.some(suffix => normalizedPath.endsWith(suffix));
|
|
}
|
|
|
|
function createStatePreview(options) {
|
|
const { createInstallState } = require('../install-state');
|
|
return createInstallState(options);
|
|
}
|
|
|
|
function buildCopyFileOperation({
|
|
moduleId,
|
|
sourcePath,
|
|
sourceRelativePath,
|
|
destinationPath,
|
|
strategy,
|
|
contentTransform,
|
|
}) {
|
|
return {
|
|
kind: 'copy-file',
|
|
moduleId,
|
|
sourcePath,
|
|
sourceRelativePath,
|
|
destinationPath,
|
|
strategy,
|
|
ownership: 'managed',
|
|
scaffoldOnly: false,
|
|
...(contentTransform ? { contentTransform } : {}),
|
|
};
|
|
}
|
|
|
|
function readJsonObject(filePath, label) {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
} catch (error) {
|
|
throw new Error(`Failed to parse ${label} at ${filePath}: ${error.message}`);
|
|
}
|
|
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new Error(`Invalid ${label} at ${filePath}: expected a JSON object`);
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function materializeClaudeSettingsOperation(sourceRoot, operation) {
|
|
const sourcePath = path.join(sourceRoot, operation.sourceRelativePath);
|
|
if (!fs.existsSync(sourcePath)) {
|
|
return [];
|
|
}
|
|
|
|
// Stable ids and descriptions live in hooks/hooks.metadata.json; readHooksConfig
|
|
// merges them back so managed settings entries keep their ids.
|
|
const hooksConfig = readHooksConfig(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 [
|
|
{
|
|
kind: 'merge-json',
|
|
moduleId: operation.moduleId,
|
|
sourceRelativePath: operation.sourceRelativePath,
|
|
destinationPath: operation.destinationPath,
|
|
strategy: operation.strategy || 'merge-json',
|
|
ownership: operation.ownership || 'managed',
|
|
scaffoldOnly: Object.hasOwn(operation, 'scaffoldOnly') ? operation.scaffoldOnly : false,
|
|
mergePayload: readJsonObject(path.join(sourceRoot, operation.sourceRelativePath), operation.sourceRelativePath)
|
|
}
|
|
];
|
|
}
|
|
|
|
const sourcePath = path.join(sourceRoot, operation.sourceRelativePath);
|
|
if (!fs.existsSync(sourcePath)) {
|
|
return [];
|
|
}
|
|
|
|
if (isGeneratedRuntimeSourcePath(operation.sourceRelativePath)) {
|
|
return [];
|
|
}
|
|
|
|
const stat = fs.statSync(sourcePath);
|
|
if (stat.isFile()) {
|
|
return [
|
|
buildCopyFileOperation({
|
|
moduleId: operation.moduleId,
|
|
sourcePath,
|
|
sourceRelativePath: operation.sourceRelativePath,
|
|
destinationPath: operation.destinationPath,
|
|
strategy: operation.strategy,
|
|
contentTransform: operation.contentTransform,
|
|
})
|
|
];
|
|
}
|
|
|
|
const relativeFiles = listFilesRecursive(sourcePath).filter(relativeFile => {
|
|
const sourceRelativePath = path.join(operation.sourceRelativePath, relativeFile);
|
|
return !isGeneratedRuntimeSourcePath(sourceRelativePath);
|
|
});
|
|
return relativeFiles.map(relativeFile => {
|
|
const sourceRelativePath = path.join(operation.sourceRelativePath, relativeFile);
|
|
return buildCopyFileOperation({
|
|
moduleId: operation.moduleId,
|
|
sourcePath: path.join(sourcePath, relativeFile),
|
|
sourceRelativePath,
|
|
destinationPath: path.join(operation.destinationPath, relativeFile),
|
|
strategy: operation.strategy,
|
|
contentTransform: operation.contentTransform,
|
|
});
|
|
});
|
|
}
|
|
|
|
function isSelectedAntigravityLegacyRule(operation, ruleLanguages) {
|
|
const normalizedSourcePath = String(operation.sourceRelativePath || '').replace(/\\/g, '/');
|
|
if (!normalizedSourcePath.startsWith('rules/')) {
|
|
return true;
|
|
}
|
|
|
|
const namespace = normalizedSourcePath.split('/')[1];
|
|
return namespace === 'common' || ruleLanguages.includes(namespace);
|
|
}
|
|
|
|
function dedupeCopyFileOperations(operations) {
|
|
// A `copy-file` operation fully overwrites its destination, so when several
|
|
// of them target the same path (e.g. a generic `commands/<name>.md` shadowed
|
|
// by an OpenCode `.opencode/commands/<name>.md` override) only the last one
|
|
// actually determines the installed content. Recording the shadowed earlier
|
|
// writes in install-state makes `doctor` report perpetual drift and drives
|
|
// `repair` to clobber the override with the generic source (issue #2414).
|
|
// Keep only the last `copy-file` per destination - matching the sequential
|
|
// apply order in applyInstallPlan - and leave every other operation kind
|
|
// (e.g. accumulating `merge-json` writes into a shared config) untouched and
|
|
// in order.
|
|
const lastCopyIndexByDestination = new Map();
|
|
operations.forEach((operation, index) => {
|
|
if (operation.kind === 'copy-file' && operation.destinationPath) {
|
|
lastCopyIndexByDestination.set(operation.destinationPath, index);
|
|
}
|
|
});
|
|
|
|
return operations.filter((operation, index) => {
|
|
if (operation.kind !== 'copy-file' || !operation.destinationPath) {
|
|
return true;
|
|
}
|
|
return lastCopyIndexByDestination.get(operation.destinationPath) === index;
|
|
});
|
|
}
|
|
|
|
function createManifestInstallPlan(options = {}) {
|
|
const sourceRoot = options.sourceRoot || getSourceRoot();
|
|
const projectRoot = options.projectRoot || process.cwd();
|
|
const target = options.target || 'claude';
|
|
const legacyLanguages = Array.isArray(options.legacyLanguages) ? [...options.legacyLanguages] : [];
|
|
const requestProfileId = Object.hasOwn(options, 'requestProfileId') ? options.requestProfileId : options.profileId || null;
|
|
const requestModuleIds = Object.hasOwn(options, 'requestModuleIds') ? [...options.requestModuleIds] : Array.isArray(options.moduleIds) ? [...options.moduleIds] : [];
|
|
const requestIncludeComponentIds = Object.hasOwn(options, 'requestIncludeComponentIds')
|
|
? [...options.requestIncludeComponentIds]
|
|
: Array.isArray(options.includeComponentIds)
|
|
? [...options.includeComponentIds]
|
|
: [];
|
|
const requestExcludeComponentIds = Object.hasOwn(options, 'requestExcludeComponentIds')
|
|
? [...options.requestExcludeComponentIds]
|
|
: Array.isArray(options.excludeComponentIds)
|
|
? [...options.excludeComponentIds]
|
|
: [];
|
|
const plan = resolveInstallPlan({
|
|
repoRoot: sourceRoot,
|
|
projectRoot,
|
|
homeDir: options.homeDir,
|
|
env: resolveInvocationEnvironment(options),
|
|
profileId: options.profileId || null,
|
|
moduleIds: options.moduleIds || [],
|
|
includeComponentIds: options.includeComponentIds || [],
|
|
excludeComponentIds: options.excludeComponentIds || [],
|
|
target,
|
|
exemptValidationCodes: options.exemptValidationCodes || [],
|
|
});
|
|
const adapter = getInstallTargetAdapter(target);
|
|
const materializedOperations = plan.operations.flatMap(operation => (
|
|
materializeScaffoldOperation(sourceRoot, operation)
|
|
));
|
|
const ruleLanguages = Array.isArray(options.ruleLanguages) ? [...options.ruleLanguages] : [];
|
|
const operations = dedupeCopyFileOperations(
|
|
options.legacyMode && target === 'antigravity'
|
|
? materializedOperations.filter(operation => (
|
|
isSelectedAntigravityLegacyRule(operation, ruleLanguages)
|
|
))
|
|
: materializedOperations
|
|
);
|
|
const source = {
|
|
repoVersion: getPackageVersion(sourceRoot),
|
|
repoCommit: getRepoCommit(sourceRoot),
|
|
manifestVersion: getManifestVersion(sourceRoot)
|
|
};
|
|
const statePreview = createStatePreview({
|
|
adapter,
|
|
targetRoot: plan.targetRoot,
|
|
installStatePath: plan.installStatePath,
|
|
request: {
|
|
profile: requestProfileId,
|
|
modules: requestModuleIds,
|
|
includeComponents: requestIncludeComponentIds,
|
|
excludeComponents: requestExcludeComponentIds,
|
|
legacyLanguages,
|
|
legacyMode: Boolean(options.legacyMode)
|
|
},
|
|
resolution: {
|
|
selectedModules: plan.selectedModuleIds,
|
|
skippedModules: plan.skippedModuleIds
|
|
},
|
|
operations,
|
|
source
|
|
});
|
|
|
|
return disableUnselectedOpenCodeHooks({
|
|
mode: options.mode || 'manifest',
|
|
sourceRoot,
|
|
target,
|
|
adapter: {
|
|
id: adapter.id,
|
|
target: adapter.target,
|
|
kind: adapter.kind
|
|
},
|
|
homeDir: plan.homeDir,
|
|
targetRoot: plan.targetRoot,
|
|
installRoot: plan.targetRoot,
|
|
installStatePath: plan.installStatePath,
|
|
warnings: Array.isArray(options.warnings) ? [...options.warnings] : [],
|
|
languages: legacyLanguages,
|
|
legacyLanguages,
|
|
profileId: plan.profileId,
|
|
requestedModuleIds: plan.requestedModuleIds,
|
|
explicitModuleIds: plan.explicitModuleIds,
|
|
includedComponentIds: plan.includedComponentIds,
|
|
excludedComponentIds: plan.excludedComponentIds,
|
|
selectedModuleIds: plan.selectedModuleIds,
|
|
skippedModuleIds: plan.skippedModuleIds,
|
|
excludedModuleIds: plan.excludedModuleIds,
|
|
operations,
|
|
statePreview
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
buildCopyFileOperation,
|
|
createManifestInstallPlan,
|
|
createStatePreview,
|
|
dedupeCopyFileOperations,
|
|
getManifestVersion,
|
|
getPackageVersion,
|
|
getRepoCommit,
|
|
getSourceRoot,
|
|
listFilesRecursive,
|
|
readJsonObject,
|
|
};
|