mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-31 06:03:29 +08:00
* Add install.ps1 PowerShell wrapper and tests Add a Windows-native PowerShell wrapper (install.ps1) that resolves symlinks and delegates to the Node-based installer runtime. Update README with PowerShell usage examples and cross-platform npx entrypoint guidance. Point the ecc-install bin to the Node installer (scripts/install-apply.js) in package.json (and refresh package-lock), include install.ps1 in package files, and add tests: a new install-ps1.test.js and a tweak to install-sh.test.js to skip on Windows. These changes provide native Windows installer support while keeping npm-compatible cross-platform invocation. * Improve tests for Windows HOME/USERPROFILE Make tests more cross-platform by ensuring HOME and USERPROFILE are kept in sync and by normalizing test file paths for display. - tests/lib/session-adapters.test.js: set USERPROFILE when temporarily setting HOME and restore previous USERPROFILE on teardown. - tests/run-all.js: use a normalized displayPath (forward-slash separated) for logging and error messages so output is consistent across platforms. - tests/scripts/ecc.test.js & tests/scripts/session-inspect.test.js: build envOverrides from options.env and add HOME <-> USERPROFILE fallbacks so spawned child processes receive both variables when only one is provided. These changes prevent test failures and inconsistent logs on Windows where USERPROFILE is used instead of HOME. * Fix Windows paths and test flakiness Improve cross-platform behavior and test stability. - Remove unused createLegacyInstallPlan import from install-lifecycle.js. - Change resolveInstallConfigPath to use path.normalize(path.join(cwd, configPath)) to produce normalized relative paths. - Tests: add toBashPath and normalizedRelativePath helpers to normalize Windows paths for bash and comparisons. - Make cleanupTestDir retry rmSync on transient Windows errors (EPERM/EBUSY/ENOTEMPTY) with short backoff using sleepMs. - Ensure spawned test processes receive USERPROFILE and convert repo/detect paths to bash format when invoking bash. These changes reduce Windows-specific failures and flakiness in the test suite and tidy up a small unused import.
1226 lines
32 KiB
JavaScript
1226 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 {
|
|
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,
|
|
};
|