Files
ECC/scripts/lib/install-lifecycle.js
T

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
};