mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 21:53:28 +08:00
* fix: strengthen install lifecycle adapters * fix: restore template content on uninstall
1227 lines
32 KiB
JavaScript
1227 lines
32 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const { resolveInstallPlan, loadInstallManifests } = require('./install-manifests');
|
|
const { readInstallState, writeInstallState } = require('./install-state');
|
|
const {
|
|
createLegacyInstallPlan,
|
|
createManifestInstallPlan,
|
|
} = require('./install-executor');
|
|
const {
|
|
getInstallTargetAdapter,
|
|
listInstallTargetAdapters,
|
|
} = require('./install-targets/registry');
|
|
|
|
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]);
|
|
}
|
|
|
|
function getManagedOperations(state) {
|
|
return Array.isArray(state && state.operations)
|
|
? state.operations.filter(operation => operation.ownership === 'managed')
|
|
: [];
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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) {
|
|
const candidateKeys = [
|
|
'renderedContent',
|
|
'content',
|
|
'managedContent',
|
|
'expectedContent',
|
|
'templateOutput',
|
|
];
|
|
|
|
for (const key of candidateKeys) {
|
|
if (typeof operation[key] === 'string') {
|
|
return operation[key];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getOperationJsonPayload(operation) {
|
|
const candidateKeys = [
|
|
'mergePayload',
|
|
'managedPayload',
|
|
'payload',
|
|
'value',
|
|
'expectedValue',
|
|
];
|
|
|
|
for (const key of candidateKeys) {
|
|
if (operation[key] !== undefined) {
|
|
return parseJsonLikeValue(operation[key], `${operation.kind}.${key}`);
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function getOperationPreviousContent(operation) {
|
|
const candidateKeys = [
|
|
'previousContent',
|
|
'originalContent',
|
|
'backupContent',
|
|
];
|
|
|
|
for (const key of candidateKeys) {
|
|
if (typeof operation[key] === 'string') {
|
|
return operation[key];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getOperationPreviousJson(operation) {
|
|
const candidateKeys = [
|
|
'previousValue',
|
|
'previousJson',
|
|
'originalValue',
|
|
];
|
|
|
|
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;
|
|
}
|
|
|
|
return Object.entries(expectedValue).every(([key, value]) => (
|
|
Object.prototype.hasOwnProperty.call(actualValue, key)
|
|
&& jsonContainsSubset(actualValue[key], value)
|
|
));
|
|
}
|
|
|
|
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,
|
|
sourcePath: resolveOperationSourcePath(repoRoot, operation),
|
|
};
|
|
});
|
|
}
|
|
|
|
function buildRecordedStatePreview(state, context, operations) {
|
|
return {
|
|
...state,
|
|
operations: operations.map(operation => ({ ...operation })),
|
|
source: {
|
|
...state.source,
|
|
repoVersion: context.packageVersion,
|
|
manifestVersion: context.manifestVersion,
|
|
},
|
|
lastValidatedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function shouldRepairFromRecordedOperations(state) {
|
|
return getManagedOperations(state).some(operation => operation.kind !== 'copy-file');
|
|
}
|
|
|
|
function executeRepairOperation(repoRoot, operation) {
|
|
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}`);
|
|
}
|
|
|
|
const currentValue = fs.existsSync(operation.destinationPath)
|
|
? readJsonFile(operation.destinationPath)
|
|
: {};
|
|
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}`);
|
|
}
|
|
|
|
function executeUninstallOperation(operation) {
|
|
if (operation.kind === 'copy-file') {
|
|
if (!fs.existsSync(operation.destinationPath)) {
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: [],
|
|
};
|
|
}
|
|
|
|
fs.rmSync(operation.destinationPath, { force: true });
|
|
return {
|
|
removedPaths: [operation.destinationPath],
|
|
cleanupTargets: [operation.destinationPath],
|
|
};
|
|
}
|
|
|
|
if (operation.kind === 'render-template') {
|
|
const previousContent = getOperationPreviousContent(operation);
|
|
if (previousContent !== null) {
|
|
ensureParentDir(operation.destinationPath);
|
|
fs.writeFileSync(operation.destinationPath, previousContent);
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: [],
|
|
};
|
|
}
|
|
|
|
const previousJson = getOperationPreviousJson(operation);
|
|
if (previousJson !== undefined) {
|
|
ensureParentDir(operation.destinationPath);
|
|
fs.writeFileSync(operation.destinationPath, formatJson(previousJson));
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: [],
|
|
};
|
|
}
|
|
|
|
if (!fs.existsSync(operation.destinationPath)) {
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: [],
|
|
};
|
|
}
|
|
|
|
fs.rmSync(operation.destinationPath, { force: true });
|
|
return {
|
|
removedPaths: [operation.destinationPath],
|
|
cleanupTargets: [operation.destinationPath],
|
|
};
|
|
}
|
|
|
|
if (operation.kind === 'merge-json') {
|
|
const previousContent = getOperationPreviousContent(operation);
|
|
if (previousContent !== null) {
|
|
ensureParentDir(operation.destinationPath);
|
|
fs.writeFileSync(operation.destinationPath, previousContent);
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: [],
|
|
};
|
|
}
|
|
|
|
const previousJson = getOperationPreviousJson(operation);
|
|
if (previousJson !== undefined) {
|
|
ensureParentDir(operation.destinationPath);
|
|
fs.writeFileSync(operation.destinationPath, formatJson(previousJson));
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: [],
|
|
};
|
|
}
|
|
|
|
if (!fs.existsSync(operation.destinationPath)) {
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: [],
|
|
};
|
|
}
|
|
|
|
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],
|
|
cleanupTargets: [operation.destinationPath],
|
|
};
|
|
}
|
|
|
|
ensureParentDir(operation.destinationPath);
|
|
fs.writeFileSync(operation.destinationPath, formatJson(nextValue));
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: [],
|
|
};
|
|
}
|
|
|
|
if (operation.kind === 'remove') {
|
|
const previousContent = getOperationPreviousContent(operation);
|
|
if (previousContent !== null) {
|
|
ensureParentDir(operation.destinationPath);
|
|
fs.writeFileSync(operation.destinationPath, previousContent);
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: [],
|
|
};
|
|
}
|
|
|
|
const previousJson = getOperationPreviousJson(operation);
|
|
if (previousJson !== undefined) {
|
|
ensureParentDir(operation.destinationPath);
|
|
fs.writeFileSync(operation.destinationPath, formatJson(previousJson));
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: [],
|
|
};
|
|
}
|
|
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: [],
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unsupported uninstall operation kind: ${operation.kind}`);
|
|
}
|
|
|
|
function inspectManagedOperation(repoRoot, operation) {
|
|
const destinationPath = operation.destinationPath;
|
|
if (!destinationPath) {
|
|
return {
|
|
status: 'invalid-destination',
|
|
operation,
|
|
};
|
|
}
|
|
|
|
if (operation.kind === 'remove') {
|
|
if (fs.existsSync(destinationPath)) {
|
|
return {
|
|
status: 'drifted',
|
|
operation,
|
|
destinationPath,
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: 'ok',
|
|
operation,
|
|
destinationPath,
|
|
};
|
|
}
|
|
|
|
if (!fs.existsSync(destinationPath)) {
|
|
return {
|
|
status: 'missing',
|
|
operation,
|
|
destinationPath,
|
|
};
|
|
}
|
|
|
|
if (operation.kind === 'copy-file') {
|
|
const sourcePath = resolveOperationSourcePath(repoRoot, operation);
|
|
if (!sourcePath || !fs.existsSync(sourcePath)) {
|
|
return {
|
|
status: 'missing-source',
|
|
operation,
|
|
destinationPath,
|
|
sourcePath,
|
|
};
|
|
}
|
|
|
|
if (!areFilesEqual(sourcePath, destinationPath)) {
|
|
return {
|
|
status: 'drifted',
|
|
operation,
|
|
destinationPath,
|
|
sourcePath,
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: 'ok',
|
|
operation,
|
|
destinationPath,
|
|
sourcePath,
|
|
};
|
|
}
|
|
|
|
if (operation.kind === 'render-template') {
|
|
const renderedContent = getOperationTextContent(operation);
|
|
if (renderedContent === null) {
|
|
return {
|
|
status: 'unverified',
|
|
operation,
|
|
destinationPath,
|
|
};
|
|
}
|
|
|
|
if (readFileUtf8(destinationPath) !== renderedContent) {
|
|
return {
|
|
status: 'drifted',
|
|
operation,
|
|
destinationPath,
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: 'ok',
|
|
operation,
|
|
destinationPath,
|
|
};
|
|
}
|
|
|
|
if (operation.kind === 'merge-json') {
|
|
const payload = getOperationJsonPayload(operation);
|
|
if (payload === undefined) {
|
|
return {
|
|
status: 'unverified',
|
|
operation,
|
|
destinationPath,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const currentValue = readJsonFile(destinationPath);
|
|
if (!jsonContainsSubset(currentValue, payload)) {
|
|
return {
|
|
status: 'drifted',
|
|
operation,
|
|
destinationPath,
|
|
};
|
|
}
|
|
} catch (_error) {
|
|
return {
|
|
status: 'drifted',
|
|
operation,
|
|
destinationPath,
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: 'ok',
|
|
operation,
|
|
destinationPath,
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: 'unverified',
|
|
operation,
|
|
destinationPath,
|
|
};
|
|
}
|
|
|
|
function summarizeManagedOperationHealth(repoRoot, operations) {
|
|
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: [],
|
|
});
|
|
}
|
|
|
|
function buildDiscoveryRecord(adapter, context) {
|
|
const installTargetInput = {
|
|
homeDir: context.homeDir,
|
|
projectRoot: context.projectRoot,
|
|
repoRoot: context.projectRoot,
|
|
};
|
|
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,
|
|
kind: adapter.kind,
|
|
},
|
|
targetRoot,
|
|
installStatePath,
|
|
exists: false,
|
|
state: null,
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const state = readInstallState(installStatePath);
|
|
return {
|
|
adapter: {
|
|
id: adapter.id,
|
|
target: adapter.target,
|
|
kind: adapter.kind,
|
|
},
|
|
targetRoot,
|
|
installStatePath,
|
|
exists: true,
|
|
state,
|
|
error: null,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
adapter: {
|
|
id: adapter.id,
|
|
target: adapter.target,
|
|
kind: adapter.kind,
|
|
},
|
|
targetRoot,
|
|
installStatePath,
|
|
exists: true,
|
|
state: null,
|
|
error: error.message,
|
|
};
|
|
}
|
|
}
|
|
|
|
function discoverInstalledStates(options = {}) {
|
|
const context = {
|
|
homeDir: options.homeDir || process.env.HOME,
|
|
projectRoot: options.projectRoot || process.cwd(),
|
|
};
|
|
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,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
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),
|
|
issues,
|
|
};
|
|
}
|
|
|
|
const state = record.state;
|
|
if (!state) {
|
|
return {
|
|
...record,
|
|
status: 'missing',
|
|
issues,
|
|
};
|
|
}
|
|
|
|
if (!fs.existsSync(state.target.root)) {
|
|
issues.push(buildIssue(
|
|
'error',
|
|
'missing-target-root',
|
|
`Target root does not exist: ${state.target.root}`
|
|
));
|
|
}
|
|
|
|
if (state.target.root !== record.targetRoot) {
|
|
issues.push(buildIssue(
|
|
'warning',
|
|
'target-root-mismatch',
|
|
`Recorded target root differs from current target root (${record.targetRoot})`,
|
|
{
|
|
recordedTargetRoot: state.target.root,
|
|
currentTargetRoot: record.targetRoot,
|
|
}
|
|
));
|
|
}
|
|
|
|
if (state.target.installStatePath !== record.installStatePath) {
|
|
issues.push(buildIssue(
|
|
'warning',
|
|
'install-state-path-mismatch',
|
|
`Recorded install-state path differs from current path (${record.installStatePath})`,
|
|
{
|
|
recordedInstallStatePath: state.target.installStatePath,
|
|
currentInstallStatePath: record.installStatePath,
|
|
}
|
|
));
|
|
}
|
|
|
|
const managedOperations = getManagedOperations(state);
|
|
const operationHealth = summarizeManagedOperationHealth(context.repoRoot, managedOperations);
|
|
const missingManagedOperations = operationHealth.missing;
|
|
|
|
if (missingManagedOperations.length > 0) {
|
|
issues.push(buildIssue(
|
|
'error',
|
|
'missing-managed-files',
|
|
`${missingManagedOperations.length} managed file(s) are missing`,
|
|
{
|
|
paths: missingManagedOperations.map(entry => entry.destinationPath),
|
|
}
|
|
));
|
|
}
|
|
|
|
if (operationHealth.drifted.length > 0) {
|
|
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),
|
|
}
|
|
));
|
|
}
|
|
|
|
if (operationHealth.missingSource.length > 0) {
|
|
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),
|
|
}
|
|
));
|
|
}
|
|
|
|
if (operationHealth.unverified.length > 0) {
|
|
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),
|
|
}
|
|
));
|
|
}
|
|
|
|
if (state.source.manifestVersion !== context.manifestVersion) {
|
|
issues.push(buildIssue(
|
|
'warning',
|
|
'manifest-version-mismatch',
|
|
`Recorded manifest version ${state.source.manifestVersion} differs from current manifest version ${context.manifestVersion}`
|
|
));
|
|
}
|
|
|
|
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}`
|
|
));
|
|
}
|
|
|
|
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 || [],
|
|
excludeComponentIds: state.request.excludeComponents || [],
|
|
});
|
|
|
|
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',
|
|
{
|
|
expectedSelectedModules: desiredPlan.selectedModuleIds,
|
|
recordedSelectedModules: state.resolution.selectedModules,
|
|
expectedSkippedModules: desiredPlan.skippedModuleIds,
|
|
recordedSkippedModules: state.resolution.skippedModules,
|
|
}
|
|
));
|
|
}
|
|
} catch (error) {
|
|
issues.push(buildIssue(
|
|
'error',
|
|
'resolution-unavailable',
|
|
error.message
|
|
));
|
|
}
|
|
}
|
|
|
|
return {
|
|
...record,
|
|
status: determineStatus(issues),
|
|
issues,
|
|
};
|
|
}
|
|
|
|
function buildDoctorReport(options = {}) {
|
|
const repoRoot = options.repoRoot || DEFAULT_REPO_ROOT;
|
|
const manifests = loadInstallManifests({ repoRoot });
|
|
const records = discoverInstalledStates({
|
|
homeDir: options.homeDir,
|
|
projectRoot: options.projectRoot,
|
|
targets: options.targets,
|
|
}).filter(record => record.exists);
|
|
const context = {
|
|
repoRoot,
|
|
homeDir: options.homeDir || process.env.HOME,
|
|
projectRoot: options.projectRoot || process.cwd(),
|
|
manifestVersion: manifests.modulesVersion,
|
|
packageVersion: readPackageVersion(repoRoot),
|
|
};
|
|
const results = records.map(record => analyzeRecord(record, context));
|
|
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;
|
|
|
|
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,
|
|
});
|
|
|
|
return {
|
|
generatedAt: new Date().toISOString(),
|
|
packageVersion: context.packageVersion,
|
|
manifestVersion: context.manifestVersion,
|
|
results,
|
|
summary,
|
|
};
|
|
}
|
|
|
|
function createRepairPlanFromRecord(record, context) {
|
|
const state = record.state;
|
|
if (!state) {
|
|
throw new Error('No install-state available for repair');
|
|
}
|
|
|
|
if (state.request.legacyMode || shouldRepairFromRecordedOperations(state)) {
|
|
const operations = hydrateRecordedOperations(context.repoRoot, getManagedOperations(state));
|
|
const statePreview = buildRecordedStatePreview(state, context, operations);
|
|
|
|
return {
|
|
mode: state.request.legacyMode ? 'legacy' : 'recorded',
|
|
target: record.adapter.target,
|
|
adapter: record.adapter,
|
|
targetRoot: state.target.root,
|
|
installRoot: state.target.root,
|
|
installStatePath: state.target.installStatePath,
|
|
warnings: [],
|
|
languages: Array.isArray(state.request.legacyLanguages)
|
|
? [...state.request.legacyLanguages]
|
|
: [],
|
|
operations,
|
|
statePreview,
|
|
};
|
|
}
|
|
|
|
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,
|
|
homeDir: context.homeDir,
|
|
});
|
|
|
|
return {
|
|
...desiredPlan,
|
|
statePreview: {
|
|
...desiredPlan.statePreview,
|
|
installedAt: state.installedAt,
|
|
lastValidatedAt: new Date().toISOString(),
|
|
},
|
|
};
|
|
}
|
|
|
|
function repairInstalledStates(options = {}) {
|
|
const repoRoot = options.repoRoot || DEFAULT_REPO_ROOT;
|
|
const manifests = loadInstallManifests({ repoRoot });
|
|
const context = {
|
|
repoRoot,
|
|
homeDir: options.homeDir || process.env.HOME,
|
|
projectRoot: options.projectRoot || process.cwd(),
|
|
manifestVersion: manifests.modulesVersion,
|
|
packageVersion: readPackageVersion(repoRoot),
|
|
};
|
|
const records = discoverInstalledStates({
|
|
homeDir: context.homeDir,
|
|
projectRoot: context.projectRoot,
|
|
targets: options.targets,
|
|
}).filter(record => record.exists);
|
|
|
|
const results = records.map(record => {
|
|
if (record.error) {
|
|
return {
|
|
adapter: record.adapter,
|
|
status: 'error',
|
|
installStatePath: record.installStatePath,
|
|
repairedPaths: [],
|
|
plannedRepairs: [],
|
|
error: record.error,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const desiredPlan = createRepairPlanFromRecord(record, context);
|
|
const operationHealth = summarizeManagedOperationHealth(context.repoRoot, desiredPlan.operations);
|
|
|
|
if (operationHealth.missingSource.length > 0) {
|
|
return {
|
|
adapter: record.adapter,
|
|
status: 'error',
|
|
installStatePath: record.installStatePath,
|
|
repairedPaths: [],
|
|
plannedRepairs: [],
|
|
error: `Missing source file(s): ${operationHealth.missingSource.map(entry => entry.sourcePath).join(', ')}`,
|
|
};
|
|
}
|
|
|
|
const repairOperations = [
|
|
...operationHealth.missing.map(entry => ({ ...entry.operation })),
|
|
...operationHealth.drifted.map(entry => ({ ...entry.operation })),
|
|
];
|
|
const plannedRepairs = repairOperations.map(operation => operation.destinationPath);
|
|
|
|
if (options.dryRun) {
|
|
return {
|
|
adapter: record.adapter,
|
|
status: plannedRepairs.length > 0 ? 'planned' : 'ok',
|
|
installStatePath: record.installStatePath,
|
|
repairedPaths: [],
|
|
plannedRepairs,
|
|
stateRefreshed: plannedRepairs.length === 0,
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
if (repairOperations.length > 0) {
|
|
for (const operation of repairOperations) {
|
|
executeRepairOperation(context.repoRoot, operation);
|
|
}
|
|
writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview);
|
|
} else {
|
|
writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview);
|
|
}
|
|
|
|
return {
|
|
adapter: record.adapter,
|
|
status: repairOperations.length > 0 ? 'repaired' : 'ok',
|
|
installStatePath: record.installStatePath,
|
|
repairedPaths: plannedRepairs,
|
|
plannedRepairs: [],
|
|
stateRefreshed: true,
|
|
error: null,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
adapter: record.adapter,
|
|
status: 'error',
|
|
installStatePath: record.installStatePath,
|
|
repairedPaths: [],
|
|
plannedRepairs: [],
|
|
error: error.message,
|
|
};
|
|
}
|
|
});
|
|
|
|
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,
|
|
});
|
|
|
|
return {
|
|
dryRun: Boolean(options.dryRun),
|
|
generatedAt: new Date().toISOString(),
|
|
results,
|
|
summary,
|
|
};
|
|
}
|
|
|
|
function cleanupEmptyParentDirs(filePath, stopAt) {
|
|
let currentPath = path.dirname(filePath);
|
|
const normalizedStopAt = path.resolve(stopAt);
|
|
|
|
while (
|
|
currentPath
|
|
&& path.resolve(currentPath).startsWith(normalizedStopAt)
|
|
&& path.resolve(currentPath) !== normalizedStopAt
|
|
) {
|
|
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,
|
|
targets: options.targets,
|
|
}).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: [],
|
|
error: record.error || 'No valid install-state available',
|
|
};
|
|
}
|
|
|
|
const state = record.state;
|
|
const plannedRemovals = Array.from(new Set([
|
|
...getManagedOperations(state).map(operation => operation.destinationPath),
|
|
state.target.installStatePath,
|
|
]));
|
|
|
|
if (options.dryRun) {
|
|
return {
|
|
adapter: record.adapter,
|
|
status: 'planned',
|
|
installStatePath: record.installStatePath,
|
|
removedPaths: [],
|
|
plannedRemovals,
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const removedPaths = [];
|
|
const cleanupTargets = [];
|
|
const operations = getManagedOperations(state);
|
|
|
|
for (const operation of operations) {
|
|
const outcome = executeUninstallOperation(operation);
|
|
removedPaths.push(...outcome.removedPaths);
|
|
cleanupTargets.push(...outcome.cleanupTargets);
|
|
}
|
|
|
|
if (fs.existsSync(state.target.installStatePath)) {
|
|
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: [],
|
|
error: null,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
adapter: record.adapter,
|
|
status: 'error',
|
|
installStatePath: record.installStatePath,
|
|
removedPaths: [],
|
|
plannedRemovals,
|
|
error: error.message,
|
|
};
|
|
}
|
|
});
|
|
|
|
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,
|
|
});
|
|
|
|
return {
|
|
dryRun: Boolean(options.dryRun),
|
|
generatedAt: new Date().toISOString(),
|
|
results,
|
|
summary,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_REPO_ROOT,
|
|
buildDoctorReport,
|
|
discoverInstalledStates,
|
|
normalizeTargets,
|
|
repairInstalledStates,
|
|
uninstallInstalledStates,
|
|
};
|