mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
Make Antigravity 2.0 installs native and safely migrate legacy state. Ensure doctor, repair, status projection, repeat installs, legacy Codex sync, and uninstall converge without losing user files. Exclude Python bytecode and harden repo-scan bootstrap guidance. Gate publishing and pull-request merges on one exact packed artifact completing install, repeat, drift, repair, status, and uninstall across Linux, macOS, and Windows. Co-authored-by: lorencifernando-coder <lorenci.fernando@gmail.com> Co-authored-by: Suliman Abdulrazzaq <suliman9000a@gmail.com> Co-authored-by: Wu Shuwen <mikewushuwen@outlook.com>
857 lines
28 KiB
JavaScript
857 lines
28 KiB
JavaScript
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { execFileSync } = require('child_process');
|
|
|
|
const { toCursorAgentRelativePath } = require('./cursor-agent-names');
|
|
const { LEGACY_INSTALL_TARGETS, parseInstallArgs } = require('./install/request');
|
|
const { SUPPORTED_INSTALL_TARGETS, listLegacyCompatibilityLanguages, resolveLegacyCompatibilitySelection, resolveInstallPlan } = require('./install-manifests');
|
|
const { getInstallTargetAdapter } = require('./install-targets/registry');
|
|
|
|
const LANGUAGE_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
const CLAUDE_ECC_NAMESPACE = 'ecc';
|
|
const EXCLUDED_GENERATED_SOURCE_SUFFIXES = ['/ecc-install-state.json', '/ecc/install-state.json'];
|
|
|
|
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 readDirectoryNames(dirPath) {
|
|
if (!fs.existsSync(dirPath)) {
|
|
return [];
|
|
}
|
|
|
|
return fs
|
|
.readdirSync(dirPath, { withFileTypes: true })
|
|
.filter(entry => entry.isDirectory())
|
|
.map(entry => entry.name)
|
|
.sort();
|
|
}
|
|
|
|
function listAvailableLanguages(sourceRoot = getSourceRoot()) {
|
|
return [...new Set([...listLegacyCompatibilityLanguages(), ...readDirectoryNames(path.join(sourceRoot, 'rules')).filter(name => name !== 'common')])].sort();
|
|
}
|
|
|
|
function validateLegacyTarget(target) {
|
|
if (LEGACY_INSTALL_TARGETS.includes(target)) {
|
|
return;
|
|
}
|
|
// A target can be fully supported yet not installable via the bare-language
|
|
// positional syntax (which is legacy-only). Guide the user to the right mode
|
|
// instead of implying the target is unknown (#2282).
|
|
if (SUPPORTED_INSTALL_TARGETS.includes(target)) {
|
|
throw new Error(
|
|
`Target '${target}' is supported, but the bare-language install syntax only accepts ${LEGACY_INSTALL_TARGETS.join(', ')}. ` +
|
|
`Install '${target}' with a component selection instead, e.g. \`install.sh --target ${target} --profile full\` ` +
|
|
`(or --modules <id,...> / --skills <id,...>).`
|
|
);
|
|
}
|
|
throw new Error(`Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}`);
|
|
}
|
|
|
|
const IGNORED_DIRECTORY_NAMES = new Set(['node_modules', '.git', '__pycache__']);
|
|
const IGNORED_FILE_EXTENSIONS = new Set(['.pyc', '.pyo', '.pyd']);
|
|
|
|
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 applyInstallPlan(plan, dependencies = {}) {
|
|
const { applyInstallPlan: applyPlan } = require('./install/apply');
|
|
return applyPlan(plan, dependencies);
|
|
}
|
|
|
|
function previewInstallPlan(plan) {
|
|
const { previewInstallPlan: previewPlan } = require('./install/apply');
|
|
return previewPlan(plan);
|
|
}
|
|
|
|
function buildCopyFileOperation({
|
|
moduleId,
|
|
sourcePath,
|
|
sourceRelativePath,
|
|
destinationPath,
|
|
strategy,
|
|
contentTransform,
|
|
}) {
|
|
return {
|
|
kind: 'copy-file',
|
|
moduleId,
|
|
sourcePath,
|
|
sourceRelativePath,
|
|
destinationPath,
|
|
strategy,
|
|
ownership: 'managed',
|
|
scaffoldOnly: false,
|
|
...(contentTransform ? { contentTransform } : {}),
|
|
};
|
|
}
|
|
|
|
function addRecursiveCopyOperations(operations, options) {
|
|
const sourceDir = path.join(options.sourceRoot, options.sourceRelativeDir);
|
|
if (!fs.existsSync(sourceDir)) {
|
|
return 0;
|
|
}
|
|
|
|
const relativeFiles = listFilesRecursive(sourceDir);
|
|
|
|
for (const relativeFile of relativeFiles) {
|
|
const sourceRelativePath = path.join(options.sourceRelativeDir, relativeFile);
|
|
const sourcePath = path.join(options.sourceRoot, sourceRelativePath);
|
|
const destinationRelativePath = typeof options.destinationRelativePathTransform === 'function' ? options.destinationRelativePathTransform(relativeFile, sourceRelativePath) : relativeFile;
|
|
if (!destinationRelativePath) {
|
|
continue;
|
|
}
|
|
const destinationPath = path.join(options.destinationDir, destinationRelativePath);
|
|
operations.push(
|
|
buildCopyFileOperation({
|
|
moduleId: options.moduleId,
|
|
sourcePath,
|
|
sourceRelativePath,
|
|
destinationPath,
|
|
strategy: options.strategy || 'preserve-relative-path',
|
|
contentTransform: options.contentTransform,
|
|
})
|
|
);
|
|
}
|
|
|
|
return relativeFiles.length;
|
|
}
|
|
|
|
function addFileCopyOperation(operations, options) {
|
|
const sourcePath = path.join(options.sourceRoot, options.sourceRelativePath);
|
|
if (!fs.existsSync(sourcePath)) {
|
|
return false;
|
|
}
|
|
|
|
operations.push(
|
|
buildCopyFileOperation({
|
|
moduleId: options.moduleId,
|
|
sourcePath,
|
|
sourceRelativePath: options.sourceRelativePath,
|
|
destinationPath: options.destinationPath,
|
|
strategy: options.strategy || 'preserve-relative-path'
|
|
})
|
|
);
|
|
|
|
return true;
|
|
}
|
|
|
|
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 addCursorAgentDataScaffoldOperations(operations, options) {
|
|
const scaffoldRoot = path.join(options.sourceRoot, 'scaffolds', 'cursor');
|
|
if (!fs.existsSync(scaffoldRoot)) {
|
|
return;
|
|
}
|
|
|
|
addFileCopyOperation(operations, {
|
|
moduleId: options.moduleId,
|
|
sourceRoot: options.sourceRoot,
|
|
sourceRelativePath: path.join('scaffolds', 'cursor', 'ecc-agent-data.json'),
|
|
destinationPath: path.join(options.targetRoot, 'ecc-agent-data.json'),
|
|
strategy: 'preserve-relative-path'
|
|
});
|
|
|
|
addFileCopyOperation(operations, {
|
|
moduleId: options.moduleId,
|
|
sourceRoot: options.sourceRoot,
|
|
sourceRelativePath: path.join('scaffolds', 'cursor', 'rules', 'ecc-agent-data-home.mdc'),
|
|
destinationPath: path.join(options.targetRoot, 'rules', 'ecc-agent-data-home.mdc'),
|
|
strategy: 'preserve-relative-path'
|
|
});
|
|
|
|
addJsonMergeOperation(operations, {
|
|
moduleId: options.moduleId,
|
|
sourceRoot: options.sourceRoot,
|
|
sourceRelativePath: path.join('scaffolds', 'cursor', 'hooks.json'),
|
|
destinationPath: path.join(options.targetRoot, 'hooks.json')
|
|
});
|
|
|
|
const cursorSessionHookDeps = [path.join('scripts', 'hooks', 'cursor-session-env.js'), path.join('scripts', 'lib', 'agent-data-home.js'), path.join('scripts', 'lib', 'utils.js')];
|
|
|
|
for (const sourceRelativePath of cursorSessionHookDeps) {
|
|
addFileCopyOperation(operations, {
|
|
moduleId: options.moduleId,
|
|
sourceRoot: options.sourceRoot,
|
|
sourceRelativePath,
|
|
destinationPath: path.join(options.targetRoot, sourceRelativePath),
|
|
strategy: 'preserve-relative-path'
|
|
});
|
|
}
|
|
}
|
|
|
|
function addJsonMergeOperation(operations, options) {
|
|
const sourcePath = path.join(options.sourceRoot, options.sourceRelativePath);
|
|
if (!fs.existsSync(sourcePath)) {
|
|
return false;
|
|
}
|
|
|
|
operations.push({
|
|
kind: 'merge-json',
|
|
moduleId: options.moduleId,
|
|
sourceRelativePath: options.sourceRelativePath,
|
|
destinationPath: options.destinationPath,
|
|
strategy: 'merge-json',
|
|
ownership: 'managed',
|
|
scaffoldOnly: false,
|
|
mergePayload: readJsonObject(sourcePath, options.sourceRelativePath)
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
function addMatchingRuleOperations(operations, options) {
|
|
const sourceDir = path.join(options.sourceRoot, options.sourceRelativeDir);
|
|
if (!fs.existsSync(sourceDir)) {
|
|
return 0;
|
|
}
|
|
|
|
const files = fs
|
|
.readdirSync(sourceDir, { withFileTypes: true })
|
|
.filter(entry => entry.isFile() && options.matcher(entry.name))
|
|
.map(entry => entry.name)
|
|
.sort();
|
|
|
|
for (const fileName of files) {
|
|
const sourceRelativePath = path.join(options.sourceRelativeDir, fileName);
|
|
const sourcePath = path.join(options.sourceRoot, sourceRelativePath);
|
|
const destinationPath = path.join(options.destinationDir, options.rename ? options.rename(fileName) : fileName);
|
|
|
|
operations.push(
|
|
buildCopyFileOperation({
|
|
moduleId: options.moduleId,
|
|
sourcePath,
|
|
sourceRelativePath,
|
|
destinationPath,
|
|
strategy: options.strategy || 'flatten-copy'
|
|
})
|
|
);
|
|
}
|
|
|
|
return files.length;
|
|
}
|
|
|
|
function isDirectoryNonEmpty(dirPath) {
|
|
return fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory() && fs.readdirSync(dirPath).length > 0;
|
|
}
|
|
|
|
function planClaudeStyleLegacyInstall(context, { adapterId, adapterRootInput, rulesDir: rulesDirOverride }) {
|
|
const adapter = getInstallTargetAdapter(adapterId);
|
|
const targetRoot = adapter.resolveRoot(adapterRootInput);
|
|
const rulesDir = rulesDirOverride || path.join(targetRoot, 'rules', CLAUDE_ECC_NAMESPACE);
|
|
const installStatePath = adapter.getInstallStatePath(adapterRootInput);
|
|
const operations = [];
|
|
const warnings = [];
|
|
|
|
if (isDirectoryNonEmpty(rulesDir)) {
|
|
warnings.push(`Destination ${rulesDir}/ already exists and files may be overwritten`);
|
|
}
|
|
|
|
addRecursiveCopyOperations(operations, {
|
|
moduleId: 'legacy-claude-rules',
|
|
sourceRoot: context.sourceRoot,
|
|
sourceRelativeDir: path.join('rules', 'common'),
|
|
destinationDir: path.join(rulesDir, 'common')
|
|
});
|
|
|
|
for (const language of context.languages) {
|
|
if (!LANGUAGE_NAME_PATTERN.test(language)) {
|
|
warnings.push(`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`);
|
|
continue;
|
|
}
|
|
|
|
const sourceDir = path.join(context.sourceRoot, 'rules', language);
|
|
if (!fs.existsSync(sourceDir)) {
|
|
warnings.push(`rules/${language}/ does not exist, skipping`);
|
|
continue;
|
|
}
|
|
|
|
addRecursiveCopyOperations(operations, {
|
|
moduleId: 'legacy-claude-rules',
|
|
sourceRoot: context.sourceRoot,
|
|
sourceRelativeDir: path.join('rules', language),
|
|
destinationDir: path.join(rulesDir, language)
|
|
});
|
|
}
|
|
|
|
return {
|
|
mode: 'legacy',
|
|
sourceRoot: context.sourceRoot,
|
|
adapter,
|
|
target: adapterId,
|
|
targetRoot,
|
|
installRoot: rulesDir,
|
|
installStatePath,
|
|
operations,
|
|
warnings,
|
|
selectedModules: ['legacy-claude-rules']
|
|
};
|
|
}
|
|
|
|
function planClaudeLegacyInstall(context) {
|
|
return planClaudeStyleLegacyInstall(context, {
|
|
adapterId: 'claude',
|
|
adapterRootInput: { homeDir: context.homeDir },
|
|
rulesDir: context.claudeRulesDir || null
|
|
});
|
|
}
|
|
|
|
function planClaudeProjectLegacyInstall(context) {
|
|
return planClaudeStyleLegacyInstall(context, {
|
|
adapterId: 'claude-project',
|
|
adapterRootInput: { repoRoot: context.projectRoot },
|
|
rulesDir: null
|
|
});
|
|
}
|
|
|
|
function planCursorLegacyInstall(context) {
|
|
const adapter = getInstallTargetAdapter('cursor');
|
|
const targetRoot = adapter.resolveRoot({ repoRoot: context.projectRoot });
|
|
const installStatePath = adapter.getInstallStatePath({ repoRoot: context.projectRoot });
|
|
const operations = [];
|
|
const warnings = [];
|
|
|
|
addMatchingRuleOperations(operations, {
|
|
moduleId: 'legacy-cursor-install',
|
|
sourceRoot: context.sourceRoot,
|
|
sourceRelativeDir: path.join('.cursor', 'rules'),
|
|
destinationDir: path.join(targetRoot, 'rules'),
|
|
matcher: fileName => /^common-.*\.md$/.test(fileName)
|
|
});
|
|
|
|
for (const language of context.languages) {
|
|
if (!LANGUAGE_NAME_PATTERN.test(language)) {
|
|
warnings.push(`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`);
|
|
continue;
|
|
}
|
|
|
|
const matches = addMatchingRuleOperations(operations, {
|
|
moduleId: 'legacy-cursor-install',
|
|
sourceRoot: context.sourceRoot,
|
|
sourceRelativeDir: path.join('.cursor', 'rules'),
|
|
destinationDir: path.join(targetRoot, 'rules'),
|
|
matcher: fileName => fileName.startsWith(`${language}-`) && fileName.endsWith('.md')
|
|
});
|
|
|
|
if (matches === 0) {
|
|
warnings.push(`No Cursor rules for '${language}' found, skipping`);
|
|
}
|
|
}
|
|
|
|
addRecursiveCopyOperations(operations, {
|
|
moduleId: 'legacy-cursor-install',
|
|
sourceRoot: context.sourceRoot,
|
|
sourceRelativeDir: path.join('.cursor', 'agents'),
|
|
destinationDir: path.join(targetRoot, 'agents'),
|
|
destinationRelativePathTransform: toCursorAgentRelativePath
|
|
});
|
|
addRecursiveCopyOperations(operations, {
|
|
moduleId: 'legacy-cursor-install',
|
|
sourceRoot: context.sourceRoot,
|
|
sourceRelativeDir: path.join('.cursor', 'skills'),
|
|
destinationDir: path.join(targetRoot, 'skills')
|
|
});
|
|
addRecursiveCopyOperations(operations, {
|
|
moduleId: 'legacy-cursor-install',
|
|
sourceRoot: context.sourceRoot,
|
|
sourceRelativeDir: path.join('.cursor', 'commands'),
|
|
destinationDir: path.join(targetRoot, 'commands')
|
|
});
|
|
addRecursiveCopyOperations(operations, {
|
|
moduleId: 'legacy-cursor-install',
|
|
sourceRoot: context.sourceRoot,
|
|
sourceRelativeDir: path.join('.cursor', 'hooks'),
|
|
destinationDir: path.join(targetRoot, 'hooks')
|
|
});
|
|
|
|
addFileCopyOperation(operations, {
|
|
moduleId: 'legacy-cursor-install',
|
|
sourceRoot: context.sourceRoot,
|
|
sourceRelativePath: path.join('.cursor', 'hooks.json'),
|
|
destinationPath: path.join(targetRoot, 'hooks.json')
|
|
});
|
|
addJsonMergeOperation(operations, {
|
|
moduleId: 'legacy-cursor-install',
|
|
sourceRoot: context.sourceRoot,
|
|
sourceRelativePath: '.mcp.json',
|
|
destinationPath: path.join(targetRoot, 'mcp.json')
|
|
});
|
|
|
|
addCursorAgentDataScaffoldOperations(operations, {
|
|
moduleId: 'legacy-cursor-install',
|
|
sourceRoot: context.sourceRoot,
|
|
targetRoot
|
|
});
|
|
|
|
return {
|
|
mode: 'legacy',
|
|
adapter,
|
|
target: 'cursor',
|
|
targetRoot,
|
|
installRoot: targetRoot,
|
|
installStatePath,
|
|
operations,
|
|
warnings,
|
|
selectedModules: ['legacy-cursor-install']
|
|
};
|
|
}
|
|
|
|
function planAntigravityLegacyInstall(context) {
|
|
const adapter = getInstallTargetAdapter('antigravity');
|
|
const targetRoot = adapter.resolveRoot({ repoRoot: context.projectRoot });
|
|
const installStatePath = adapter.getInstallStatePath({ repoRoot: context.projectRoot });
|
|
const operations = [];
|
|
const warnings = [];
|
|
|
|
if (isDirectoryNonEmpty(path.join(targetRoot, 'rules'))) {
|
|
warnings.push(`Destination ${path.join(targetRoot, 'rules')}/ already exists and files may be overwritten`);
|
|
}
|
|
|
|
addMatchingRuleOperations(operations, {
|
|
moduleId: 'legacy-antigravity-install',
|
|
sourceRoot: context.sourceRoot,
|
|
sourceRelativeDir: path.join('rules', 'common'),
|
|
destinationDir: path.join(targetRoot, 'rules'),
|
|
matcher: fileName => fileName.endsWith('.md'),
|
|
rename: fileName => `common-${fileName}`
|
|
});
|
|
|
|
for (const language of context.languages) {
|
|
if (!LANGUAGE_NAME_PATTERN.test(language)) {
|
|
warnings.push(`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`);
|
|
continue;
|
|
}
|
|
|
|
const sourceDir = path.join(context.sourceRoot, 'rules', language);
|
|
if (!fs.existsSync(sourceDir)) {
|
|
warnings.push(`rules/${language}/ does not exist, skipping`);
|
|
continue;
|
|
}
|
|
|
|
addMatchingRuleOperations(operations, {
|
|
moduleId: 'legacy-antigravity-install',
|
|
sourceRoot: context.sourceRoot,
|
|
sourceRelativeDir: path.join('rules', language),
|
|
destinationDir: path.join(targetRoot, 'rules'),
|
|
matcher: fileName => fileName.endsWith('.md'),
|
|
rename: fileName => `${language}-${fileName}`
|
|
});
|
|
}
|
|
|
|
addRecursiveCopyOperations(operations, {
|
|
moduleId: 'legacy-antigravity-install',
|
|
sourceRoot: context.sourceRoot,
|
|
sourceRelativeDir: 'commands',
|
|
destinationDir: path.join(targetRoot, 'workflows')
|
|
});
|
|
addRecursiveCopyOperations(operations, {
|
|
moduleId: 'legacy-antigravity-install',
|
|
sourceRoot: context.sourceRoot,
|
|
sourceRelativeDir: 'agents',
|
|
destinationDir: path.join(targetRoot, 'agents'),
|
|
contentTransform: 'antigravity-agent-frontmatter'
|
|
});
|
|
addRecursiveCopyOperations(operations, {
|
|
moduleId: 'legacy-antigravity-install',
|
|
sourceRoot: context.sourceRoot,
|
|
sourceRelativeDir: 'skills',
|
|
destinationDir: path.join(targetRoot, 'skills')
|
|
});
|
|
|
|
return {
|
|
mode: 'legacy',
|
|
adapter,
|
|
target: 'antigravity',
|
|
targetRoot,
|
|
installRoot: targetRoot,
|
|
installStatePath,
|
|
operations,
|
|
warnings,
|
|
selectedModules: ['legacy-antigravity-install']
|
|
};
|
|
}
|
|
|
|
function createLegacyInstallPlan(options = {}) {
|
|
const sourceRoot = options.sourceRoot || getSourceRoot();
|
|
const projectRoot = options.projectRoot || process.cwd();
|
|
const homeDir = options.homeDir || process.env.HOME || os.homedir();
|
|
const target = options.target || 'claude';
|
|
|
|
validateLegacyTarget(target);
|
|
|
|
const context = {
|
|
sourceRoot,
|
|
projectRoot,
|
|
homeDir,
|
|
languages: Array.isArray(options.languages) ? options.languages : [],
|
|
claudeRulesDir: options.claudeRulesDir || process.env.CLAUDE_RULES_DIR || null
|
|
};
|
|
|
|
let plan;
|
|
if (target === 'claude') {
|
|
plan = planClaudeLegacyInstall(context);
|
|
} else if (target === 'claude-project') {
|
|
plan = planClaudeProjectLegacyInstall(context);
|
|
} else if (target === 'cursor') {
|
|
plan = planCursorLegacyInstall(context);
|
|
} else {
|
|
plan = planAntigravityLegacyInstall(context);
|
|
}
|
|
|
|
const source = {
|
|
repoVersion: getPackageVersion(sourceRoot),
|
|
repoCommit: getRepoCommit(sourceRoot),
|
|
manifestVersion: getManifestVersion(sourceRoot)
|
|
};
|
|
|
|
const statePreview = createStatePreview({
|
|
adapter: plan.adapter,
|
|
targetRoot: plan.targetRoot,
|
|
installStatePath: plan.installStatePath,
|
|
request: {
|
|
profile: null,
|
|
modules: [],
|
|
legacyLanguages: context.languages,
|
|
legacyMode: true
|
|
},
|
|
resolution: {
|
|
selectedModules: plan.selectedModules,
|
|
skippedModules: []
|
|
},
|
|
operations: plan.operations,
|
|
source
|
|
});
|
|
|
|
return {
|
|
mode: 'legacy',
|
|
sourceRoot,
|
|
target: plan.target,
|
|
adapter: {
|
|
id: plan.adapter.id,
|
|
target: plan.adapter.target,
|
|
kind: plan.adapter.kind
|
|
},
|
|
targetRoot: plan.targetRoot,
|
|
installRoot: plan.installRoot,
|
|
installStatePath: plan.installStatePath,
|
|
warnings: plan.warnings,
|
|
languages: context.languages,
|
|
operations: plan.operations,
|
|
statePreview
|
|
};
|
|
}
|
|
|
|
function createLegacyCompatInstallPlan(options = {}) {
|
|
const sourceRoot = options.sourceRoot || getSourceRoot();
|
|
const projectRoot = options.projectRoot || process.cwd();
|
|
const target = options.target || 'claude';
|
|
const includeComponentIds = Array.isArray(options.includeComponentIds) ? [...options.includeComponentIds] : [];
|
|
const excludeComponentIds = Array.isArray(options.excludeComponentIds) ? [...options.excludeComponentIds] : [];
|
|
|
|
validateLegacyTarget(target);
|
|
|
|
const selection = resolveLegacyCompatibilitySelection({
|
|
repoRoot: sourceRoot,
|
|
target,
|
|
legacyLanguages: options.legacyLanguages || []
|
|
});
|
|
|
|
return createManifestInstallPlan({
|
|
sourceRoot,
|
|
projectRoot,
|
|
homeDir: options.homeDir,
|
|
target,
|
|
profileId: null,
|
|
moduleIds: selection.moduleIds,
|
|
includeComponentIds,
|
|
excludeComponentIds,
|
|
legacyLanguages: selection.legacyLanguages,
|
|
ruleLanguages: selection.ruleLanguages,
|
|
legacyMode: true,
|
|
requestProfileId: null,
|
|
requestModuleIds: [],
|
|
requestIncludeComponentIds: includeComponentIds,
|
|
requestExcludeComponentIds: excludeComponentIds,
|
|
mode: 'legacy-compat'
|
|
});
|
|
}
|
|
|
|
function materializeScaffoldOperation(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,
|
|
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 {
|
|
mode: options.mode || 'manifest',
|
|
sourceRoot,
|
|
target,
|
|
adapter: {
|
|
id: adapter.id,
|
|
target: adapter.target,
|
|
kind: adapter.kind
|
|
},
|
|
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 = {
|
|
SUPPORTED_INSTALL_TARGETS,
|
|
LEGACY_INSTALL_TARGETS,
|
|
applyInstallPlan,
|
|
previewInstallPlan,
|
|
createLegacyCompatInstallPlan,
|
|
createManifestInstallPlan,
|
|
createLegacyInstallPlan,
|
|
dedupeCopyFileOperations,
|
|
getSourceRoot,
|
|
listAvailableLanguages,
|
|
parseInstallArgs
|
|
};
|