mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
2058 lines
60 KiB
JavaScript
2058 lines
60 KiB
JavaScript
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const { execFileSync } = require('child_process');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const { resolveInstallPlan, loadInstallManifests } = require('./install-manifests');
|
|
const { readInstallState, validateInstallState } = require('./install-state');
|
|
const { assertWithinTrustedRoot } = require('./path-safety');
|
|
const { createManifestInstallPlan } = require('./install-executor');
|
|
const {
|
|
prepareClaudeSkillMigration,
|
|
} = require('./install/claude-skill-migration');
|
|
const {
|
|
getLegacyAntigravityLocation,
|
|
inspectLegacyAntigravityState,
|
|
} = require('./install/antigravity-legacy-migration');
|
|
const { adaptAntigravityAgent } = require('./install/antigravity-agent');
|
|
const { buildInstallIndex, rewriteRelativeLinks } = require('./install/link-rewrite');
|
|
const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry');
|
|
const OPENCODE_BUILD_ARTIFACT = path.join('.opencode', 'dist');
|
|
const OPENCODE_BUILD_SCRIPT = path.join('scripts', 'build-opencode.js');
|
|
const OPENCODE_PLUGIN_NOT_BUILT_CODE = 'opencode-plugin-not-built';
|
|
|
|
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 hasOpencodeBuildError(issues) {
|
|
return Array.isArray(issues) && issues.some(issue => issue.code === OPENCODE_PLUGIN_NOT_BUILT_CODE);
|
|
}
|
|
|
|
function getOpencodeBuildValidationIssues(context) {
|
|
return getInstallTargetAdapter('opencode').validate({
|
|
homeDir: context.homeDir,
|
|
repoRoot: context.repoRoot,
|
|
});
|
|
}
|
|
|
|
function buildOpencodePayload(repoRoot, buildRunner = execFileSync) {
|
|
buildRunner(process.execPath, [path.join(repoRoot, OPENCODE_BUILD_SCRIPT)], {
|
|
cwd: repoRoot,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
}
|
|
|
|
function formatBuildErrorMessage(error) {
|
|
const stderr = typeof error.stderr === 'string' ? error.stderr.trim() : '';
|
|
const stdout = typeof error.stdout === 'string' ? error.stdout.trim() : '';
|
|
return stderr || stdout || error.message || 'Failed to build OpenCode payload';
|
|
}
|
|
|
|
function getManagedOperations(state) {
|
|
return Array.isArray(state && state.operations) ? state.operations.filter(operation => operation.ownership === 'managed') : [];
|
|
}
|
|
|
|
function createUnsafeRepairSourceError() {
|
|
return new Error(
|
|
'Refusing unsafe repair source metadata: sources must stay within the repository.'
|
|
);
|
|
}
|
|
|
|
function assertSafeRepairSourcePath(sourcePath, repoRoot) {
|
|
try {
|
|
return assertWithinTrustedRoot(sourcePath, repoRoot, 'read repair source');
|
|
} catch {
|
|
throw createUnsafeRepairSourceError();
|
|
}
|
|
}
|
|
|
|
function resolveOperationSourcePath(repoRoot, operation) {
|
|
if (operation.sourceRelativePath) {
|
|
if (typeof operation.sourceRelativePath !== 'string') {
|
|
throw createUnsafeRepairSourceError();
|
|
}
|
|
|
|
const sourceRelativePath = operation.sourceRelativePath;
|
|
const hasParentTraversal = sourceRelativePath
|
|
.split(/[/\\]+/)
|
|
.includes('..');
|
|
const isAbsolute = path.isAbsolute(sourceRelativePath)
|
|
|| path.win32.isAbsolute(sourceRelativePath);
|
|
if (isAbsolute || hasParentTraversal) {
|
|
throw createUnsafeRepairSourceError();
|
|
}
|
|
|
|
return assertSafeRepairSourcePath(
|
|
path.resolve(repoRoot, sourceRelativePath),
|
|
repoRoot
|
|
);
|
|
}
|
|
|
|
if (!operation.sourcePath) {
|
|
return null;
|
|
}
|
|
if (
|
|
typeof operation.sourcePath !== 'string'
|
|
|| !path.isAbsolute(operation.sourcePath)
|
|
) {
|
|
throw createUnsafeRepairSourceError();
|
|
}
|
|
return assertSafeRepairSourcePath(operation.sourcePath, repoRoot);
|
|
}
|
|
|
|
function areFilesEqual(leftPath, rightPath) {
|
|
try {
|
|
return readFileNoFollow(leftPath).equals(readFileNoFollow(rightPath));
|
|
} catch (_error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function hasRecordedContentDigest(operation) {
|
|
return /^[a-f0-9]{64}$/i.test(String(operation && operation.contentSha256 || ''));
|
|
}
|
|
|
|
function fileMatchesRecordedContent(filePath, operation) {
|
|
if (!hasRecordedContentDigest(operation)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return crypto.createHash('sha256')
|
|
.update(readFileNoFollow(filePath))
|
|
.digest('hex') === operation.contentSha256.toLowerCase();
|
|
} catch (_error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isMarkdownPath(filePath) {
|
|
return /\.(md|mdx|markdown)$/i.test(String(filePath || ''));
|
|
}
|
|
|
|
function buildLinkIndexForOperations(operations, trustedRoot) {
|
|
const mappings = (operations || [])
|
|
.filter(operation => operation.kind === 'copy-file' && operation.sourceRelativePath)
|
|
.map(operation => ({
|
|
sourceRel: operation.sourceRelativePath,
|
|
destRel: path.relative(trustedRoot, operation.destinationPath),
|
|
}));
|
|
return buildInstallIndex(mappings);
|
|
}
|
|
|
|
function transformCopyFileContent(operation, content) {
|
|
if (!operation.contentTransform) {
|
|
return content;
|
|
}
|
|
if (operation.contentTransform === 'antigravity-agent-frontmatter') {
|
|
return adaptAntigravityAgent(content, operation.sourceRelativePath);
|
|
}
|
|
throw new Error(`Unknown install content transform: ${operation.contentTransform}`);
|
|
}
|
|
|
|
function getExpectedCopyFileContent(operation, content, linkIndex) {
|
|
const transformed = transformCopyFileContent(operation, content);
|
|
if (!linkIndex || !operation.sourceRelativePath || !isMarkdownPath(operation.destinationPath)) {
|
|
return transformed;
|
|
}
|
|
return rewriteRelativeLinks(transformed, {
|
|
sourceRel: operation.sourceRelativePath,
|
|
index: linkIndex,
|
|
});
|
|
}
|
|
|
|
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 getManagedDestination(
|
|
destinationPath,
|
|
trustedRoot,
|
|
action,
|
|
{ allowFinalSymlink = false } = {}
|
|
) {
|
|
if (!destinationPath || typeof destinationPath !== 'string') {
|
|
throw new Error(`Refusing to ${action}: missing destination path.`);
|
|
}
|
|
|
|
const canonicalRoot = assertWithinTrustedRoot(trustedRoot, trustedRoot, action);
|
|
const resolvedDestination = path.resolve(destinationPath);
|
|
const canonicalParent = assertWithinTrustedRoot(
|
|
path.dirname(resolvedDestination),
|
|
canonicalRoot,
|
|
action
|
|
);
|
|
const managedPath = path.join(canonicalParent, path.basename(resolvedDestination));
|
|
let stat = null;
|
|
|
|
try {
|
|
stat = fs.lstatSync(managedPath);
|
|
} catch (error) {
|
|
if (!error || (error.code !== 'ENOENT' && error.code !== 'ENOTDIR')) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (stat && stat.isSymbolicLink() && !allowFinalSymlink) {
|
|
const error = new Error(
|
|
`Refusing to ${action}: managed destination is a final symlink.`
|
|
);
|
|
error.code = 'ECC_FINAL_DESTINATION_SYMLINK';
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
canonicalRoot,
|
|
exists: stat !== null,
|
|
isFinalSymlink: Boolean(stat && stat.isSymbolicLink()),
|
|
managedPath
|
|
};
|
|
}
|
|
|
|
function ensureContainedParentDir(destinationPath, trustedRoot, action) {
|
|
const initialDestination = getManagedDestination(
|
|
destinationPath,
|
|
trustedRoot,
|
|
action
|
|
);
|
|
const { canonicalRoot, managedPath } = initialDestination;
|
|
const canonicalParent = path.dirname(managedPath);
|
|
const relativeParent = path.relative(canonicalRoot, canonicalParent);
|
|
const pathSegments = relativeParent
|
|
? relativeParent.split(path.sep).filter(Boolean)
|
|
: [];
|
|
let currentPath = canonicalRoot;
|
|
|
|
for (const segment of pathSegments) {
|
|
const validatedParent = assertWithinTrustedRoot(currentPath, canonicalRoot, action);
|
|
const nextPath = path.join(validatedParent, segment);
|
|
try {
|
|
fs.mkdirSync(nextPath);
|
|
} catch (error) {
|
|
if (!error || error.code !== 'EEXIST') {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const validatedNext = assertWithinTrustedRoot(nextPath, canonicalRoot, action);
|
|
const nextStat = fs.lstatSync(validatedNext);
|
|
if (!nextStat.isDirectory() || nextStat.isSymbolicLink()) {
|
|
throw new Error(`Refusing to ${action}: destination parent is not a trusted directory.`);
|
|
}
|
|
currentPath = validatedNext;
|
|
}
|
|
|
|
return getManagedDestination(managedPath, canonicalRoot, action).managedPath;
|
|
}
|
|
|
|
function prepareContainedWriteDestination(destinationPath, trustedRoot, action) {
|
|
return ensureContainedParentDir(destinationPath, trustedRoot, action);
|
|
}
|
|
|
|
function getContainedExistingPath(
|
|
destinationPath,
|
|
trustedRoot,
|
|
action,
|
|
{ allowFinalSymlink = false } = {}
|
|
) {
|
|
const initialDestination = getManagedDestination(
|
|
destinationPath,
|
|
trustedRoot,
|
|
action,
|
|
{ allowFinalSymlink }
|
|
);
|
|
const followsToExistingPath = fs.existsSync(initialDestination.managedPath);
|
|
if (!followsToExistingPath && !initialDestination.isFinalSymlink) {
|
|
return null;
|
|
}
|
|
|
|
const finalDestination = getManagedDestination(
|
|
initialDestination.managedPath,
|
|
trustedRoot,
|
|
action,
|
|
{ allowFinalSymlink }
|
|
);
|
|
return finalDestination.exists ? finalDestination.managedPath : null;
|
|
}
|
|
|
|
function hasSameFileIdentity(leftStat, rightStat) {
|
|
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
|
|
}
|
|
|
|
function createChangedDestinationError(action) {
|
|
return new Error(
|
|
`Refusing to ${action}: managed destination changed during the write.`
|
|
);
|
|
}
|
|
|
|
function getStableParentStat(filePath, action) {
|
|
const parentStat = fs.lstatSync(path.dirname(filePath));
|
|
if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) {
|
|
throw createChangedDestinationError(action);
|
|
}
|
|
return parentStat;
|
|
}
|
|
|
|
function assertPinnedWriteDestination(
|
|
filePath,
|
|
fileDescriptor,
|
|
expectedParentStat,
|
|
trustedRoot,
|
|
action
|
|
) {
|
|
const liveDestination = getManagedDestination(filePath, trustedRoot, action);
|
|
if (path.resolve(liveDestination.managedPath) !== path.resolve(filePath)) {
|
|
throw createChangedDestinationError(action);
|
|
}
|
|
|
|
const liveParentStat = getStableParentStat(filePath, action);
|
|
if (!hasSameFileIdentity(expectedParentStat, liveParentStat)) {
|
|
throw createChangedDestinationError(action);
|
|
}
|
|
|
|
const descriptorStat = fs.fstatSync(fileDescriptor);
|
|
const livePathStat = fs.lstatSync(liveDestination.managedPath);
|
|
if (
|
|
!descriptorStat.isFile()
|
|
|| !livePathStat.isFile()
|
|
|| livePathStat.isSymbolicLink()
|
|
|| !hasSameFileIdentity(descriptorStat, livePathStat)
|
|
) {
|
|
throw createChangedDestinationError(action);
|
|
}
|
|
}
|
|
|
|
function writeFileNoFollow(filePath, content, mode, trustedRoot, action) {
|
|
const expectedParentStat = getStableParentStat(filePath, action);
|
|
const flags = fs.constants.O_WRONLY
|
|
| fs.constants.O_CREAT
|
|
| (fs.constants.O_NOFOLLOW || 0);
|
|
const fileDescriptor = fs.openSync(filePath, flags, mode);
|
|
|
|
try {
|
|
assertPinnedWriteDestination(
|
|
filePath,
|
|
fileDescriptor,
|
|
expectedParentStat,
|
|
trustedRoot,
|
|
action
|
|
);
|
|
fs.ftruncateSync(fileDescriptor, 0);
|
|
fs.writeFileSync(fileDescriptor, content);
|
|
if (mode !== undefined) {
|
|
fs.fchmodSync(fileDescriptor, mode);
|
|
}
|
|
} finally {
|
|
fs.closeSync(fileDescriptor);
|
|
}
|
|
}
|
|
|
|
function readFileWithMetadataNoFollow(filePath, encoding) {
|
|
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
|
|
const fileDescriptor = fs.openSync(filePath, flags);
|
|
|
|
try {
|
|
const stat = fs.fstatSync(fileDescriptor);
|
|
if (!stat.isFile()) {
|
|
throw new Error(`Refusing to read non-file path: ${filePath}`);
|
|
}
|
|
return {
|
|
content: fs.readFileSync(fileDescriptor, encoding),
|
|
mode: stat.mode,
|
|
};
|
|
} finally {
|
|
fs.closeSync(fileDescriptor);
|
|
}
|
|
}
|
|
|
|
function readFileNoFollow(filePath, encoding) {
|
|
return readFileWithMetadataNoFollow(filePath, encoding).content;
|
|
}
|
|
|
|
function readJsonNoFollow(filePath) {
|
|
return JSON.parse(readFileNoFollow(filePath, 'utf8'));
|
|
}
|
|
|
|
function writeContainedFile(destinationPath, content, trustedRoot, action, mode) {
|
|
const preparedDestination = prepareContainedWriteDestination(destinationPath, trustedRoot, action);
|
|
const finalDestination = getManagedDestination(
|
|
preparedDestination,
|
|
trustedRoot,
|
|
action
|
|
).managedPath;
|
|
writeFileNoFollow(
|
|
finalDestination,
|
|
content,
|
|
mode,
|
|
trustedRoot,
|
|
action
|
|
);
|
|
return finalDestination;
|
|
}
|
|
|
|
function copyContainedFile(sourcePath, destinationPath, trustedRoot, action) {
|
|
const source = readFileWithMetadataNoFollow(sourcePath);
|
|
return writeContainedFile(
|
|
destinationPath,
|
|
source.content,
|
|
trustedRoot,
|
|
action,
|
|
source.mode & 0o777
|
|
);
|
|
}
|
|
|
|
function removeContainedPath(destinationPath, trustedRoot, action, options = {}) {
|
|
const existingDestination = getContainedExistingPath(
|
|
destinationPath,
|
|
trustedRoot,
|
|
action,
|
|
{ allowFinalSymlink: true }
|
|
);
|
|
if (!existingDestination) {
|
|
return null;
|
|
}
|
|
|
|
const managedDestination = getManagedDestination(
|
|
existingDestination,
|
|
trustedRoot,
|
|
action,
|
|
{ allowFinalSymlink: true }
|
|
);
|
|
const finalDestination = managedDestination.managedPath;
|
|
const expectedStat = fs.lstatSync(finalDestination, { bigint: true });
|
|
const quarantineDir = fs.mkdtempSync(path.join(
|
|
path.dirname(managedDestination.canonicalRoot),
|
|
'.ecc-remove-'
|
|
));
|
|
const quarantinePath = path.join(quarantineDir, path.basename(finalDestination));
|
|
|
|
try {
|
|
fs.renameSync(finalDestination, quarantinePath);
|
|
} catch (error) {
|
|
fs.rmdirSync(quarantineDir);
|
|
throw error;
|
|
}
|
|
|
|
const quarantinedStat = fs.lstatSync(quarantinePath, { bigint: true });
|
|
if (!hasSameFileIdentity(expectedStat, quarantinedStat)) {
|
|
try {
|
|
fs.renameSync(quarantinePath, finalDestination);
|
|
fs.rmdirSync(quarantineDir);
|
|
} catch (_restoreError) {
|
|
throw new Error(
|
|
`Refusing to ${action}: managed destination changed before removal; replacement preserved at ${quarantinePath}.`
|
|
);
|
|
}
|
|
throw createChangedDestinationError(action);
|
|
}
|
|
|
|
if (quarantinedStat.isDirectory() && !options.recursive) {
|
|
fs.rmdirSync(quarantinePath);
|
|
} else {
|
|
fs.rmSync(quarantinePath, options);
|
|
}
|
|
fs.rmdirSync(quarantineDir);
|
|
return finalDestination;
|
|
}
|
|
|
|
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, trustedRoot, linkIndex = null) {
|
|
// Install-state is attacker-controllable; never write/delete outside the
|
|
// adapter-derived trusted root, regardless of what the state file claims
|
|
// (GHSA-hfpv-w6mp-5g95).
|
|
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}`);
|
|
}
|
|
|
|
if (operation.contentTransform || isMarkdownPath(operation.destinationPath)) {
|
|
const source = readFileWithMetadataNoFollow(sourcePath, 'utf8');
|
|
writeContainedFile(
|
|
operation.destinationPath,
|
|
getExpectedCopyFileContent(operation, source.content, linkIndex),
|
|
trustedRoot,
|
|
'repair',
|
|
source.mode & 0o777
|
|
);
|
|
} else {
|
|
copyContainedFile(sourcePath, operation.destinationPath, trustedRoot, 'repair');
|
|
}
|
|
return operation.destinationPath;
|
|
}
|
|
|
|
if (operation.kind === 'render-template') {
|
|
const renderedContent = getOperationTextContent(operation);
|
|
if (renderedContent === null) {
|
|
throw new Error(`Missing rendered content for repair: ${operation.destinationPath}`);
|
|
}
|
|
|
|
writeContainedFile(operation.destinationPath, renderedContent, trustedRoot, 'repair');
|
|
return operation.destinationPath;
|
|
}
|
|
|
|
if (operation.kind === 'merge-json') {
|
|
const payload = getOperationJsonPayload(operation);
|
|
if (payload === undefined) {
|
|
throw new Error(`Missing merge payload for repair: ${operation.destinationPath}`);
|
|
}
|
|
|
|
const existingDestination = getContainedExistingPath(operation.destinationPath, trustedRoot, 'repair');
|
|
const currentValue = existingDestination
|
|
? readJsonNoFollow(
|
|
getManagedDestination(existingDestination, trustedRoot, 'repair').managedPath
|
|
)
|
|
: {};
|
|
const mergedValue = deepMergeJson(currentValue, payload);
|
|
|
|
writeContainedFile(operation.destinationPath, formatJson(mergedValue), trustedRoot, 'repair');
|
|
return operation.destinationPath;
|
|
}
|
|
|
|
if (operation.kind === 'remove') {
|
|
const removedPath = removeContainedPath(
|
|
operation.destinationPath,
|
|
trustedRoot,
|
|
'repair',
|
|
{ recursive: true, force: true }
|
|
);
|
|
return removedPath ? operation.destinationPath : null;
|
|
}
|
|
|
|
throw new Error(`Unsupported repair operation kind: ${operation.kind}`);
|
|
}
|
|
|
|
function executeUninstallOperation(operation, trustedRoot, options = {}) {
|
|
// Confine deletes to the trusted install root (GHSA-hfpv-w6mp-5g95).
|
|
if (operation.kind === 'copy-file') {
|
|
if (options.preserveDriftedCopies) {
|
|
const existingDestination = getContainedExistingPath(
|
|
operation.destinationPath,
|
|
trustedRoot,
|
|
'uninstall',
|
|
{ allowFinalSymlink: true }
|
|
);
|
|
if (!existingDestination) {
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: []
|
|
};
|
|
}
|
|
if (fs.lstatSync(existingDestination).isSymbolicLink()) {
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: [],
|
|
retainedPaths: [operation.destinationPath]
|
|
};
|
|
}
|
|
const recordedDigest = operation.contentSha256;
|
|
const currentDigest = /^[a-f0-9]{64}$/i.test(recordedDigest || '')
|
|
? crypto.createHash('sha256')
|
|
.update(readFileNoFollow(existingDestination))
|
|
.digest('hex')
|
|
: null;
|
|
if (!currentDigest || currentDigest !== recordedDigest.toLowerCase()) {
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: [],
|
|
retainedPaths: [operation.destinationPath]
|
|
};
|
|
}
|
|
}
|
|
|
|
const removedPath = removeContainedPath(
|
|
operation.destinationPath,
|
|
trustedRoot,
|
|
'uninstall',
|
|
{ force: true }
|
|
);
|
|
if (!removedPath) {
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: []
|
|
};
|
|
}
|
|
|
|
return {
|
|
removedPaths: [operation.destinationPath],
|
|
cleanupTargets: [removedPath]
|
|
};
|
|
}
|
|
|
|
if (operation.kind === 'render-template') {
|
|
const previousContent = getOperationPreviousContent(operation);
|
|
if (previousContent !== null) {
|
|
writeContainedFile(operation.destinationPath, previousContent, trustedRoot, 'uninstall');
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: []
|
|
};
|
|
}
|
|
|
|
const previousJson = getOperationPreviousJson(operation);
|
|
if (previousJson !== undefined) {
|
|
writeContainedFile(operation.destinationPath, formatJson(previousJson), trustedRoot, 'uninstall');
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: []
|
|
};
|
|
}
|
|
|
|
const removedPath = removeContainedPath(
|
|
operation.destinationPath,
|
|
trustedRoot,
|
|
'uninstall',
|
|
{ force: true }
|
|
);
|
|
if (!removedPath) {
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: []
|
|
};
|
|
}
|
|
|
|
return {
|
|
removedPaths: [operation.destinationPath],
|
|
cleanupTargets: [removedPath]
|
|
};
|
|
}
|
|
|
|
if (operation.kind === 'merge-json') {
|
|
const previousContent = getOperationPreviousContent(operation);
|
|
if (previousContent !== null) {
|
|
writeContainedFile(operation.destinationPath, previousContent, trustedRoot, 'uninstall');
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: []
|
|
};
|
|
}
|
|
|
|
const previousJson = getOperationPreviousJson(operation);
|
|
if (previousJson !== undefined) {
|
|
writeContainedFile(operation.destinationPath, formatJson(previousJson), trustedRoot, 'uninstall');
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: []
|
|
};
|
|
}
|
|
|
|
const existingDestination = getContainedExistingPath(
|
|
operation.destinationPath,
|
|
trustedRoot,
|
|
'uninstall'
|
|
);
|
|
if (!existingDestination) {
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: []
|
|
};
|
|
}
|
|
|
|
const payload = getOperationJsonPayload(operation);
|
|
if (payload === undefined) {
|
|
throw new Error(`Missing merge payload for uninstall: ${operation.destinationPath}`);
|
|
}
|
|
|
|
const currentValue = readJsonNoFollow(
|
|
getManagedDestination(existingDestination, trustedRoot, 'uninstall').managedPath
|
|
);
|
|
const nextValue = deepRemoveJsonSubset(currentValue, payload);
|
|
if (nextValue === JSON_REMOVE_SENTINEL) {
|
|
const removedPath = removeContainedPath(
|
|
operation.destinationPath,
|
|
trustedRoot,
|
|
'uninstall',
|
|
{ force: true }
|
|
);
|
|
return {
|
|
removedPaths: removedPath ? [operation.destinationPath] : [],
|
|
cleanupTargets: removedPath ? [removedPath] : []
|
|
};
|
|
}
|
|
|
|
writeContainedFile(operation.destinationPath, formatJson(nextValue), trustedRoot, 'uninstall');
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: []
|
|
};
|
|
}
|
|
|
|
if (operation.kind === 'remove') {
|
|
const previousContent = getOperationPreviousContent(operation);
|
|
if (previousContent !== null) {
|
|
writeContainedFile(operation.destinationPath, previousContent, trustedRoot, 'uninstall');
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: []
|
|
};
|
|
}
|
|
|
|
const previousJson = getOperationPreviousJson(operation);
|
|
if (previousJson !== undefined) {
|
|
writeContainedFile(operation.destinationPath, formatJson(previousJson), trustedRoot, 'uninstall');
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: []
|
|
};
|
|
}
|
|
|
|
return {
|
|
removedPaths: [],
|
|
cleanupTargets: []
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unsupported uninstall operation kind: ${operation.kind}`);
|
|
}
|
|
|
|
function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = null) {
|
|
const destinationPath = operation.destinationPath;
|
|
if (!destinationPath) {
|
|
return {
|
|
status: 'invalid-destination',
|
|
operation
|
|
};
|
|
}
|
|
|
|
let managedDestination;
|
|
try {
|
|
managedDestination = getManagedDestination(
|
|
destinationPath,
|
|
trustedRoot,
|
|
'inspect managed operation',
|
|
{ allowFinalSymlink: operation.kind === 'remove' }
|
|
);
|
|
} catch (error) {
|
|
return {
|
|
status: 'unsafe-destination',
|
|
operation,
|
|
destinationPath,
|
|
reason: error && error.code === 'ECC_FINAL_DESTINATION_SYMLINK'
|
|
? 'final-symlink'
|
|
: 'outside-root'
|
|
};
|
|
}
|
|
|
|
const inspectedPath = managedDestination.managedPath;
|
|
|
|
if (operation.kind === 'remove') {
|
|
if (managedDestination.exists) {
|
|
return {
|
|
status: 'drifted',
|
|
operation,
|
|
destinationPath
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: 'ok',
|
|
operation,
|
|
destinationPath
|
|
};
|
|
}
|
|
|
|
let copySourcePath = null;
|
|
if (operation.kind === 'copy-file') {
|
|
try {
|
|
copySourcePath = resolveOperationSourcePath(repoRoot, operation);
|
|
} catch {
|
|
return {
|
|
status: 'unsafe-source',
|
|
operation,
|
|
destinationPath
|
|
};
|
|
}
|
|
}
|
|
|
|
if (!managedDestination.exists) {
|
|
return {
|
|
status: 'missing',
|
|
operation,
|
|
destinationPath
|
|
};
|
|
}
|
|
|
|
if (operation.kind === 'copy-file') {
|
|
if (!copySourcePath || !fs.existsSync(copySourcePath)) {
|
|
return {
|
|
status: 'missing-source',
|
|
operation,
|
|
destinationPath,
|
|
sourcePath: copySourcePath
|
|
};
|
|
}
|
|
|
|
let contentMatches;
|
|
try {
|
|
contentMatches = hasRecordedContentDigest(operation)
|
|
? fileMatchesRecordedContent(inspectedPath, operation)
|
|
: operation.contentTransform || isMarkdownPath(operation.destinationPath)
|
|
? readFileNoFollow(inspectedPath, 'utf8') === getExpectedCopyFileContent(
|
|
operation,
|
|
readFileNoFollow(copySourcePath, 'utf8'),
|
|
linkIndex
|
|
)
|
|
: areFilesEqual(copySourcePath, inspectedPath);
|
|
} catch (_error) {
|
|
return {
|
|
status: 'unverified',
|
|
operation,
|
|
destinationPath,
|
|
sourcePath: copySourcePath
|
|
};
|
|
}
|
|
|
|
if (!contentMatches) {
|
|
return {
|
|
status: 'drifted',
|
|
operation,
|
|
destinationPath,
|
|
sourcePath: copySourcePath
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: 'ok',
|
|
operation,
|
|
destinationPath,
|
|
sourcePath: copySourcePath
|
|
};
|
|
}
|
|
|
|
if (operation.kind === 'render-template') {
|
|
const renderedContent = getOperationTextContent(operation);
|
|
if (renderedContent === null) {
|
|
return {
|
|
status: 'unverified',
|
|
operation,
|
|
destinationPath
|
|
};
|
|
}
|
|
|
|
try {
|
|
if (readFileNoFollow(inspectedPath, 'utf8') !== renderedContent) {
|
|
return {
|
|
status: 'drifted',
|
|
operation,
|
|
destinationPath
|
|
};
|
|
}
|
|
} catch {
|
|
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 = readJsonNoFollow(inspectedPath);
|
|
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, trustedRoot, operations) {
|
|
const linkIndex = buildLinkIndexForOperations(operations, trustedRoot);
|
|
return operations.reduce(
|
|
(summary, operation) => {
|
|
const inspection = inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex);
|
|
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 === 'unsafe-source') {
|
|
summary.unsafeSource.push(inspection);
|
|
} else if (inspection.status === 'unsafe-destination') {
|
|
summary.unsafeDestination.push(inspection);
|
|
} else if (inspection.status === 'unverified' || inspection.status === 'invalid-destination') {
|
|
summary.unverified.push(inspection);
|
|
}
|
|
return summary;
|
|
},
|
|
{
|
|
missing: [],
|
|
drifted: [],
|
|
missingSource: [],
|
|
unsafeSource: [],
|
|
unsafeDestination: [],
|
|
unverified: []
|
|
}
|
|
);
|
|
}
|
|
|
|
function getUnsafeManagedDestinationError(operationHealth) {
|
|
const hasFinalSymlink = operationHealth.unsafeDestination.some(
|
|
inspection => inspection.reason === 'final-symlink'
|
|
);
|
|
if (hasFinalSymlink) {
|
|
return 'Refusing unsafe managed destination: final symlink detected.';
|
|
}
|
|
return 'Refusing unsafe managed destination outside adapter-derived install root.';
|
|
}
|
|
|
|
function getUnsafeOperationResult(record, operationHealth) {
|
|
const error = operationHealth.unsafeDestination.length > 0
|
|
? getUnsafeManagedDestinationError(operationHealth)
|
|
: operationHealth.unsafeSource.length > 0
|
|
? createUnsafeRepairSourceError().message
|
|
: null;
|
|
if (!error) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
adapter: record.adapter,
|
|
status: 'error',
|
|
installStatePath: record.installStatePath,
|
|
repairedPaths: [],
|
|
plannedRepairs: [],
|
|
stateRefreshed: false,
|
|
error
|
|
};
|
|
}
|
|
|
|
function buildDiscoveryRecord(adapter, context, location = null, knownState = null) {
|
|
const installTargetInput = {
|
|
homeDir: context.homeDir,
|
|
projectRoot: context.projectRoot,
|
|
repoRoot: context.projectRoot
|
|
};
|
|
const targetRoot = location
|
|
? location.targetRoot
|
|
: adapter.resolveRoot(installTargetInput);
|
|
const installStatePath = location
|
|
? location.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,
|
|
legacy: Boolean(location)
|
|
};
|
|
}
|
|
|
|
if (knownState) {
|
|
return {
|
|
adapter: {
|
|
id: adapter.id,
|
|
target: adapter.target,
|
|
kind: adapter.kind
|
|
},
|
|
targetRoot,
|
|
installStatePath,
|
|
exists: true,
|
|
state: knownState,
|
|
error: null,
|
|
legacy: Boolean(location)
|
|
};
|
|
}
|
|
|
|
try {
|
|
const state = readInstallState(installStatePath);
|
|
return {
|
|
adapter: {
|
|
id: adapter.id,
|
|
target: adapter.target,
|
|
kind: adapter.kind
|
|
},
|
|
targetRoot,
|
|
installStatePath,
|
|
exists: true,
|
|
state,
|
|
error: null,
|
|
legacy: Boolean(location)
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
adapter: {
|
|
id: adapter.id,
|
|
target: adapter.target,
|
|
kind: adapter.kind
|
|
},
|
|
targetRoot,
|
|
installStatePath,
|
|
exists: true,
|
|
state: null,
|
|
error: error.message,
|
|
legacy: Boolean(location)
|
|
};
|
|
}
|
|
}
|
|
|
|
function discoverInstalledStates(options = {}) {
|
|
const context = {
|
|
homeDir: options.homeDir || process.env.HOME || os.homedir(),
|
|
projectRoot: options.projectRoot || process.cwd()
|
|
};
|
|
const targets = normalizeTargets(options.targets);
|
|
|
|
return targets.flatMap(target => {
|
|
const adapter = getInstallTargetAdapter(target);
|
|
const canonicalRecord = buildDiscoveryRecord(adapter, context);
|
|
if (adapter.target !== 'antigravity') {
|
|
return [canonicalRecord];
|
|
}
|
|
|
|
const legacyLocation = getLegacyAntigravityLocation(context.projectRoot);
|
|
const legacyInspection = inspectLegacyAntigravityState(legacyLocation);
|
|
if (
|
|
path.resolve(legacyLocation.installStatePath) === path.resolve(canonicalRecord.installStatePath)
|
|
|| legacyInspection.status === 'absent'
|
|
|| legacyInspection.status === 'invalid'
|
|
) {
|
|
return [canonicalRecord];
|
|
}
|
|
|
|
if (legacyInspection.status === 'unreadable') {
|
|
return [canonicalRecord, {
|
|
adapter: {
|
|
id: adapter.id,
|
|
target: adapter.target,
|
|
kind: adapter.kind,
|
|
},
|
|
targetRoot: legacyLocation.targetRoot,
|
|
installStatePath: legacyLocation.installStatePath,
|
|
exists: true,
|
|
state: null,
|
|
error: legacyInspection.error,
|
|
legacy: true,
|
|
}];
|
|
}
|
|
|
|
return [
|
|
canonicalRecord,
|
|
buildDiscoveryRecord(adapter, context, legacyLocation, legacyInspection.state),
|
|
];
|
|
});
|
|
}
|
|
|
|
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.legacy) {
|
|
issues.push(buildIssue(
|
|
'warning',
|
|
'legacy-antigravity-layout',
|
|
'Legacy Antigravity install-state remains under .agent. Review and move any preserved modified or unmanaged files out of .agent, then rerun the Antigravity install to finish migration.'
|
|
));
|
|
}
|
|
|
|
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,
|
|
record.targetRoot,
|
|
managedOperations
|
|
);
|
|
const missingManagedOperations = operationHealth.missing;
|
|
|
|
if (operationHealth.unsafeDestination.length > 0) {
|
|
issues.push(
|
|
buildIssue(
|
|
'error',
|
|
'unsafe-managed-destination',
|
|
`${operationHealth.unsafeDestination.length} managed operation(s) target an unsafe destination`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (operationHealth.unsafeSource.length > 0) {
|
|
issues.push(
|
|
buildIssue(
|
|
'error',
|
|
'unsafe-repair-source',
|
|
`${operationHealth.unsafeSource.length} managed operation(s) reference unsafe repair source metadata`
|
|
)
|
|
);
|
|
}
|
|
|
|
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 || os.homedir(),
|
|
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, options = {}) {
|
|
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,
|
|
exemptValidationCodes: options.exemptValidationCodes || [],
|
|
});
|
|
|
|
return {
|
|
...desiredPlan,
|
|
statePreview: {
|
|
...desiredPlan.statePreview,
|
|
installedAt: state.installedAt,
|
|
lastValidatedAt: new Date().toISOString()
|
|
}
|
|
};
|
|
}
|
|
|
|
function buildAdapterDerivedStatePreview(statePreview, record) {
|
|
return {
|
|
...statePreview,
|
|
target: {
|
|
...statePreview.target,
|
|
id: record.adapter.id,
|
|
target: record.adapter.target,
|
|
kind: record.adapter.kind,
|
|
root: record.targetRoot,
|
|
installStatePath: record.installStatePath
|
|
}
|
|
};
|
|
}
|
|
|
|
function assertValidInstallStateForWrite(state, label) {
|
|
const validation = validateInstallState(state);
|
|
if (validation.valid) {
|
|
return;
|
|
}
|
|
|
|
const details = validation.errors
|
|
.map(error => `${error.instancePath || '/'} ${error.message}`)
|
|
.join('; ');
|
|
throw new Error(`Invalid install-state (${label}): ${details}`);
|
|
}
|
|
|
|
function writeRefreshedInstallState(record, statePreview) {
|
|
const trustedStatePreview = buildAdapterDerivedStatePreview(statePreview, record);
|
|
const stateWithCurrentDigests = {
|
|
...trustedStatePreview,
|
|
operations: (trustedStatePreview.operations || []).map(operation => {
|
|
if (!operation.destinationPath) {
|
|
return { ...operation };
|
|
}
|
|
try {
|
|
const contentSha256 = crypto.createHash('sha256')
|
|
.update(readFileNoFollow(operation.destinationPath))
|
|
.digest('hex');
|
|
return { ...operation, contentSha256 };
|
|
} catch (_error) {
|
|
const { contentSha256: _staleDigest, ...operationWithoutDigest } = operation;
|
|
return operationWithoutDigest;
|
|
}
|
|
}),
|
|
};
|
|
assertValidInstallStateForWrite(stateWithCurrentDigests, record.installStatePath);
|
|
return writeContainedFile(
|
|
record.installStatePath,
|
|
formatJson(stateWithCurrentDigests),
|
|
record.targetRoot,
|
|
'repair'
|
|
);
|
|
}
|
|
|
|
function prepareRepairMigration(plan, record) {
|
|
const trustedPlan = {
|
|
...plan,
|
|
adapter: record.adapter,
|
|
targetRoot: record.targetRoot,
|
|
installRoot: record.targetRoot,
|
|
installStatePath: record.installStatePath,
|
|
statePreview: buildAdapterDerivedStatePreview(plan.statePreview, record),
|
|
};
|
|
const migration = prepareClaudeSkillMigration(trustedPlan);
|
|
return {
|
|
migration,
|
|
plan: {
|
|
...trustedPlan,
|
|
operations: migration.finalState.operations,
|
|
statePreview: migration.finalState,
|
|
warnings: [
|
|
...(Array.isArray(plan.warnings) ? plan.warnings : []),
|
|
...migration.warnings,
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
function repairInstalledStates(options = {}) {
|
|
const repoRoot = options.repoRoot || DEFAULT_REPO_ROOT;
|
|
const manifests = loadInstallManifests({ repoRoot });
|
|
const context = {
|
|
repoRoot,
|
|
homeDir: options.homeDir || process.env.HOME || os.homedir(),
|
|
projectRoot: options.projectRoot || process.cwd(),
|
|
manifestVersion: manifests.modulesVersion,
|
|
packageVersion: readPackageVersion(repoRoot)
|
|
};
|
|
const buildOpencodeRunner = typeof options.buildOpencodePayload === 'function'
|
|
? options.buildOpencodePayload
|
|
: buildOpencodePayload;
|
|
const records = discoverInstalledStates({
|
|
homeDir: context.homeDir,
|
|
projectRoot: context.projectRoot,
|
|
targets: options.targets
|
|
}).filter(record => record.exists && !record.legacy);
|
|
|
|
const results = records.map(record => {
|
|
if (record.error) {
|
|
return {
|
|
adapter: record.adapter,
|
|
status: 'error',
|
|
installStatePath: record.installStatePath,
|
|
repairedPaths: [],
|
|
plannedRepairs: [],
|
|
error: record.error
|
|
};
|
|
}
|
|
|
|
try {
|
|
const needsOpencodeBuild = record.adapter.target === 'opencode'
|
|
&& hasOpencodeBuildError(getOpencodeBuildValidationIssues(context));
|
|
const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT);
|
|
|
|
if (needsOpencodeBuild && options.dryRun) {
|
|
const rawPlan = createRepairPlanFromRecord(record, context, {
|
|
exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE],
|
|
});
|
|
const { plan: desiredPlan } = prepareRepairMigration(rawPlan, record);
|
|
const operationHealth = summarizeManagedOperationHealth(
|
|
context.repoRoot,
|
|
record.targetRoot,
|
|
desiredPlan.operations
|
|
);
|
|
const unsafeOperationResult = getUnsafeOperationResult(
|
|
record,
|
|
operationHealth
|
|
);
|
|
if (unsafeOperationResult) {
|
|
return unsafeOperationResult;
|
|
}
|
|
const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))];
|
|
const plannedRepairs = [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)];
|
|
|
|
return {
|
|
adapter: record.adapter,
|
|
status: 'planned',
|
|
installStatePath: record.installStatePath,
|
|
repairedPaths: [],
|
|
plannedRepairs,
|
|
stateRefreshed: false,
|
|
warnings: desiredPlan.warnings,
|
|
error: null
|
|
};
|
|
}
|
|
|
|
if (needsOpencodeBuild) {
|
|
try {
|
|
buildOpencodeRunner(context.repoRoot);
|
|
} catch (error) {
|
|
return {
|
|
adapter: record.adapter,
|
|
status: 'error',
|
|
installStatePath: record.installStatePath,
|
|
repairedPaths: [],
|
|
plannedRepairs: [],
|
|
error: formatBuildErrorMessage(error)
|
|
};
|
|
}
|
|
}
|
|
|
|
const rawPlan = createRepairPlanFromRecord(record, context);
|
|
const {
|
|
migration,
|
|
plan: desiredPlan,
|
|
} = prepareRepairMigration(rawPlan, record);
|
|
const operationHealth = summarizeManagedOperationHealth(
|
|
context.repoRoot,
|
|
record.targetRoot,
|
|
desiredPlan.operations
|
|
);
|
|
|
|
const unsafeOperationResult = getUnsafeOperationResult(
|
|
record,
|
|
operationHealth
|
|
);
|
|
if (unsafeOperationResult) {
|
|
return unsafeOperationResult;
|
|
}
|
|
|
|
if (operationHealth.missingSource.length > 0) {
|
|
return {
|
|
adapter: record.adapter,
|
|
status: 'error',
|
|
installStatePath: record.installStatePath,
|
|
repairedPaths: [],
|
|
plannedRepairs: [],
|
|
warnings: desiredPlan.warnings,
|
|
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 repairLinkIndex = buildLinkIndexForOperations(desiredPlan.operations, record.targetRoot);
|
|
const legacyMigrationPaths = migration.legacyOperationsToRemove.map(
|
|
operation => operation.destinationPath
|
|
);
|
|
const plannedRepairs = [...new Set([
|
|
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
|
|
...repairOperations.map(operation => operation.destinationPath),
|
|
...legacyMigrationPaths,
|
|
])];
|
|
|
|
if (options.dryRun) {
|
|
return {
|
|
adapter: record.adapter,
|
|
status: plannedRepairs.length > 0 ? 'planned' : 'ok',
|
|
installStatePath: record.installStatePath,
|
|
repairedPaths: [],
|
|
plannedRepairs,
|
|
stateRefreshed: plannedRepairs.length === 0,
|
|
warnings: desiredPlan.warnings,
|
|
error: null
|
|
};
|
|
}
|
|
|
|
const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0;
|
|
const repairedPaths = needsOpencodeBuild ? [opencodeBuildRepairPath] : [];
|
|
if (migration.requiresBridgeState && (repairOperations.length > 0 || hasLegacyMigration)) {
|
|
writeRefreshedInstallState(record, migration.bridgeState);
|
|
}
|
|
|
|
for (const operation of repairOperations) {
|
|
const repairedPath = executeRepairOperation(
|
|
context.repoRoot,
|
|
operation,
|
|
record.targetRoot,
|
|
repairLinkIndex
|
|
);
|
|
if (repairedPath) {
|
|
repairedPaths.push(repairedPath);
|
|
}
|
|
}
|
|
if (hasLegacyMigration) {
|
|
for (const operation of migration.legacyOperationsToRemove) {
|
|
const removedPath = removeContainedPath(
|
|
operation.destinationPath,
|
|
record.targetRoot,
|
|
'migrate managed Claude skill',
|
|
{ force: true }
|
|
);
|
|
if (removedPath) {
|
|
repairedPaths.push(removedPath);
|
|
}
|
|
}
|
|
}
|
|
const changedInstalledBytes = repairOperations.length > 0
|
|
|| needsOpencodeBuild
|
|
|| hasLegacyMigration;
|
|
const statePreviewToWrite = changedInstalledBytes
|
|
? desiredPlan.statePreview
|
|
: {
|
|
...desiredPlan.statePreview,
|
|
installedAt: record.state.installedAt,
|
|
source: { ...record.state.source },
|
|
};
|
|
writeRefreshedInstallState(record, statePreviewToWrite);
|
|
|
|
return {
|
|
adapter: record.adapter,
|
|
status: (repairOperations.length > 0 || needsOpencodeBuild || hasLegacyMigration)
|
|
? 'repaired'
|
|
: 'ok',
|
|
installStatePath: record.installStatePath,
|
|
repairedPaths,
|
|
plannedRepairs: [],
|
|
stateRefreshed: true,
|
|
warnings: desiredPlan.warnings,
|
|
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) {
|
|
const trustedStopAt = assertWithinTrustedRoot(stopAt, stopAt, 'clean up');
|
|
const trustedFilePath = assertWithinTrustedRoot(filePath, trustedStopAt, 'clean up');
|
|
let currentPath = path.dirname(trustedFilePath);
|
|
|
|
while (currentPath) {
|
|
const relativePath = path.relative(trustedStopAt, currentPath);
|
|
const isContained = relativePath !== '..'
|
|
&& !relativePath.startsWith(`..${path.sep}`)
|
|
&& !path.isAbsolute(relativePath);
|
|
if (!isContained || relativePath === '') {
|
|
break;
|
|
}
|
|
|
|
let validatedPath = assertWithinTrustedRoot(currentPath, trustedStopAt, 'clean up');
|
|
if (!fs.existsSync(validatedPath)) {
|
|
currentPath = path.dirname(validatedPath);
|
|
continue;
|
|
}
|
|
|
|
validatedPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up');
|
|
const stat = fs.lstatSync(validatedPath);
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
break;
|
|
}
|
|
|
|
validatedPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up');
|
|
if (fs.readdirSync(validatedPath).length > 0) {
|
|
break;
|
|
}
|
|
|
|
const finalPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up');
|
|
const removedPath = removeContainedPath(finalPath, trustedStopAt, 'clean up');
|
|
if (!removedPath) break;
|
|
currentPath = path.dirname(removedPath);
|
|
}
|
|
}
|
|
|
|
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 managedOperations = getManagedOperations(state);
|
|
if (record.legacy && managedOperations.length > 0) {
|
|
return {
|
|
adapter: record.adapter,
|
|
status: 'partial',
|
|
installStatePath: record.installStatePath,
|
|
removedPaths: [],
|
|
plannedRemovals: [],
|
|
retainedPaths: managedOperations.map(operation => operation.destinationPath),
|
|
warning: 'Legacy Antigravity files were preserved because their provenance cannot be revalidated during uninstall. Rerun the Antigravity installer to migrate verified files, then review .agent manually.',
|
|
error: null
|
|
};
|
|
}
|
|
const plannedRemovals = Array.from(new Set([
|
|
...managedOperations.map(operation => operation.destinationPath),
|
|
record.installStatePath
|
|
]));
|
|
|
|
if (options.dryRun) {
|
|
return {
|
|
adapter: record.adapter,
|
|
status: 'planned',
|
|
installStatePath: record.installStatePath,
|
|
removedPaths: [],
|
|
plannedRemovals,
|
|
error: null
|
|
};
|
|
}
|
|
|
|
try {
|
|
const removedPaths = [];
|
|
const cleanupTargets = [];
|
|
const retainedPaths = [];
|
|
const operations = getManagedOperations(state);
|
|
|
|
for (const operation of operations) {
|
|
const outcome = executeUninstallOperation(operation, record.targetRoot, {
|
|
preserveDriftedCopies: true,
|
|
});
|
|
removedPaths.push(...outcome.removedPaths);
|
|
cleanupTargets.push(...outcome.cleanupTargets);
|
|
retainedPaths.push(...(outcome.retainedPaths || []));
|
|
}
|
|
|
|
if (retainedPaths.length === 0) {
|
|
const removedStatePath = removeContainedPath(
|
|
record.installStatePath,
|
|
record.targetRoot,
|
|
'uninstall',
|
|
{ force: true }
|
|
);
|
|
if (removedStatePath) {
|
|
removedPaths.push(record.installStatePath);
|
|
cleanupTargets.push(removedStatePath);
|
|
}
|
|
}
|
|
|
|
for (const cleanupTarget of cleanupTargets) {
|
|
cleanupEmptyParentDirs(cleanupTarget, record.targetRoot);
|
|
}
|
|
|
|
return {
|
|
adapter: record.adapter,
|
|
status: retainedPaths.length > 0 ? 'partial' : 'uninstalled',
|
|
installStatePath: record.installStatePath,
|
|
removedPaths,
|
|
retainedPaths: [...new Set(retainedPaths)].sort(),
|
|
plannedRemovals: [],
|
|
warning: retainedPaths.length > 0
|
|
? 'Modified or unverifiable managed files were preserved together with install-state for review.'
|
|
: null,
|
|
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),
|
|
partialCount: accumulator.partialCount + (result.status === 'partial' ? 1 : 0),
|
|
errorCount: accumulator.errorCount + (result.status === 'error' ? 1 : 0)
|
|
}),
|
|
{
|
|
checkedCount: 0,
|
|
uninstalledCount: 0,
|
|
plannedRemovalCount: 0,
|
|
partialCount: 0,
|
|
errorCount: 0
|
|
}
|
|
);
|
|
|
|
return {
|
|
dryRun: Boolean(options.dryRun),
|
|
generatedAt: new Date().toISOString(),
|
|
results,
|
|
summary
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_REPO_ROOT,
|
|
buildDoctorReport,
|
|
discoverInstalledStates,
|
|
normalizeTargets,
|
|
repairInstalledStates,
|
|
uninstallInstalledStates
|
|
};
|