Merge remote-tracking branch 'origin/main' into maint/pr-2866-current

This commit is contained in:
haelyra
2026-08-28 18:29:35 -04:00
118 changed files with 3850 additions and 368 deletions
+8 -3
View File
@@ -173,6 +173,13 @@ function runExternalCommand(command, args, options = {}) {
return result;
}
function legacyMigrationWarning(record) {
if (record.legacyLayout === 'opencode') {
return 'Found only a legacy OpenCode ~/.opencode install-state. Run the OpenCode installer once to migrate it to the configured OpenCode directory before auto-updating.';
}
return 'Found only a legacy Antigravity .agent install-state. Run the Antigravity installer once to migrate it to .agents before auto-updating.';
}
function runAutoUpdate(options = {}, dependencies = {}) {
const discover = dependencies.discoverInstalledStates || discoverInstalledStates;
const execute = dependencies.runExternalCommand || runExternalCommand;
@@ -187,9 +194,7 @@ function runAutoUpdate(options = {}, dependencies = {}) {
const records = discoveredRecords.filter(record => record.exists && !record.legacy);
const legacyRecords = discoveredRecords.filter(record => record.exists && record.legacy);
const warnings = records.length === 0 && legacyRecords.length > 0
? [
'Found only a legacy Antigravity .agent install-state. Run the Antigravity installer once to migrate it to .agents before auto-updating.',
]
? [...new Set(legacyRecords.map(legacyMigrationWarning))]
: [];
const results = [];
+4
View File
@@ -15,6 +15,10 @@ const ignoredDirs = new Set([
'.dmux',
'.next',
'.venv',
'.pytest_cache',
'.ruff_cache',
'.turbo',
'.cache',
'coverage',
'venv',
]);
+1 -3
View File
@@ -18,9 +18,7 @@ const PROFILES_SCHEMA_PATH = path.join(REPO_ROOT, 'schemas/install-profiles.sche
const COMPONENTS_SCHEMA_PATH = path.join(REPO_ROOT, 'schemas/install-components.schema.json');
const CURATED_SKILLS_DIR = path.join(REPO_ROOT, 'skills');
// Empty by default; add only curated skills that are intentionally unshipped.
const INTENTIONALLY_UNSHIPPED_SKILL_IDS = new Set([
'skill-comply', // meta/measurement dev-skill; ships committed .pyc artifacts and a nested .gitignore, revisit after packaging cleanup
]);
const INTENTIONALLY_UNSHIPPED_SKILL_IDS = new Set([]);
const COMPONENT_FAMILY_PREFIXES = {
baseline: 'baseline:',
language: 'lang:',
+1
View File
@@ -96,6 +96,7 @@ function main() {
const report = buildDoctorReport({
repoRoot: require('path').join(__dirname, '..'),
homeDir: process.env.HOME || os.homedir(),
env: process.env,
projectRoot: process.cwd(),
targets: options.targets,
});
+1 -1
View File
@@ -41,7 +41,7 @@ const COMMANDS = {
},
nasiko: {
script: 'nasiko.js',
description: 'Install or inspect the optional pinned Nasiko control-plane CLI',
description: 'Install or inspect the optional pinned Nasiko CLI lifecycle bridge',
},
memory: {
script: 'memory.js',
+24 -12
View File
@@ -188,6 +188,29 @@ async function main() {
}
}
// ECC's LLM summary helper launches a one-shot Claude subprocess whose Stop
// hooks inherit this dedicated marker. Skip that known internal session
// before touching session state. Transcript cardinality is not a safe proxy:
// an ordinary user session may legitimately contain one prompt and no tools.
if (process.env.ECC_LLM_SUMMARY_SUBPROCESS === '1') {
log('[SessionEnd] Skipped ECC LLM summary subprocess');
return;
}
// Read known transcripts before resolving session metadata or touching the
// session directory. Missing, unreadable, or unparseable transcript data keeps
// the established fallback behavior because it cannot be classified reliably.
let summary = null;
let transcriptExists = false;
if (transcriptPath) {
transcriptExists = fs.existsSync(transcriptPath);
if (transcriptExists) {
summary = extractSessionSummary(transcriptPath);
} else {
log(`[SessionEnd] Transcript not found: ${transcriptPath}`);
}
}
const sessionsDir = getSessionsDir();
const today = getDateString();
// Derive shortId from transcript_path UUID when available, using the SAME
@@ -218,21 +241,10 @@ async function main() {
const currentTime = getTimeString();
// Try to extract summary from transcript
let summary = null;
if (transcriptPath) {
if (fs.existsSync(transcriptPath)) {
summary = extractSessionSummary(transcriptPath);
} else {
log(`[SessionEnd] Transcript not found: ${transcriptPath}`);
}
}
// Decide whether to call LLM for a richer summary.
// Triggers: context remaining < 20%, or every 50 user messages as a baseline.
let llmSummary = null;
if (transcriptPath && summary && fs.existsSync(transcriptPath)) {
if (transcriptPath && summary && transcriptExists) {
const contextPct = getContextRemainingPct(transcriptPath);
const isContextLow = contextPct !== null && contextPct < getContextThreshold();
const interval = parseInt(process.env.ECC_LLM_SUMMARY_INTERVAL || '50', 10);
+2 -1
View File
@@ -39,7 +39,7 @@ Targets:
antigravity - Install rules, workflows, skills, and agents to ./.agents/
codex - Install shared agents/config into ~/.codex/
gemini - Install project-local Gemini config into ./.gemini/
opencode - Install shared commands/hooks/config into ~/.opencode/
opencode - Install into OPENCODE_CONFIG_DIR, XDG_CONFIG_HOME/opencode, or ~/.config/opencode/
codebuddy - Install commands, agents, skills, and flattened rules into ./.codebuddy/
joycode - Install commands, agents, skills, and flattened rules into ./.joycode/
qwen - Install commands, agents, skills, rules, and Qwen config into ~/.qwen/
@@ -164,6 +164,7 @@ async function main() {
const rawPlan = createInstallPlanFromRequest(request, {
projectRoot: process.cwd(),
homeDir: process.env.HOME || os.homedir(),
env: process.env,
claudeRulesDir: process.env.CLAUDE_RULES_DIR || null,
});
+48 -3
View File
@@ -475,6 +475,42 @@ function listLegacyCandidates(codexHome) {
return candidates;
}
function hasMarkerBlock(codexHome) {
const agentsPath = path.join(codexHome, 'AGENTS.md');
try {
const snapshot = readRegularFileNoFollow(agentsPath, 'utf8');
if (snapshot) {
const stripped = stripMarkerBlock(snapshot.content);
return stripped !== snapshot.content;
}
} catch (error) {
// Only ENOENT means "no AGENTS.md" → no marker. Any other error
// (EACCES, EMFILE, EISDIR, symlink-ELOOP, ...) is an indeterminate
// inspection result and must propagate so callers do not read it as
// "clean home". Throwing here is intentional per the repo coding
// guideline: "Always handle errors explicitly at every level and never
// silently swallow errors."
if (error && error.code === 'ENOENT') return false;
throw error;
}
return false;
}
function resolveCodexHome(codexHome) {
return path.resolve(codexHome || process.env.CODEX_HOME || path.join(process.env.HOME || os.homedir(), '.codex'));
}
function legacyCodexSyncStateExists(codexHome) {
const resolvedCodexHome = resolveCodexHome(codexHome);
return readStateIfPresent(getStatePath(resolvedCodexHome)) !== null;
}
function detectLegacyCodexSync(codexHome) {
const resolvedCodexHome = resolveCodexHome(codexHome);
if (readStateIfPresent(getStatePath(resolvedCodexHome))) return true;
return hasMarkerBlock(resolvedCodexHome);
}
function uninstallLegacyCodexSync(options = {}) {
const codexHome = path.resolve(options.codexHome || process.env.CODEX_HOME || path.join(process.env.HOME || os.homedir(), '.codex'));
const statePath = getStatePath(codexHome);
@@ -494,17 +530,24 @@ function uninstallLegacyCodexSync(options = {}) {
const stripped = stripMarkerBlock(content);
if (stripped !== content) {
plannedRemovals.push(`${agentsPath}#ecc-marker-block`);
if (!dryRun) replaceOpenedRegularFile(openedAgents, stripped, openedAgents.stat.mode & 0o777);
if (!dryRun) {
replaceOpenedRegularFile(openedAgents, stripped, openedAgents.stat.mode & 0o777);
removedPaths.push(agentsPath);
}
}
}
} catch (_error) {
retainedPaths.push(agentsPath);
if (_error.code !== 'ENOENT') retainedPaths.push(agentsPath);
} finally {
if (openedAgents) fs.closeSync(openedAgents.descriptor);
}
retainedPaths.push(...listLegacyCandidates(codexHome));
const hasWork = plannedRemovals.length > 0 || removedPaths.length > 0;
const status = dryRun
? (hasWork || retainedPaths.length > 0 ? 'planned' : 'not-found')
: (retainedPaths.length > 0 ? 'partial' : (hasWork ? 'uninstalled' : 'not-found'));
return {
status: dryRun ? 'planned' : retainedPaths.length > 0 ? 'partial' : plannedRemovals.length > 0 ? 'uninstalled' : 'not-found',
status,
statePath: null,
plannedRemovals,
removedPaths,
@@ -594,8 +637,10 @@ module.exports = {
END_MARKER,
SCHEMA,
beginLegacySyncState,
detectLegacyCodexSync,
finalizeLegacySyncState,
getStatePath,
legacyCodexSyncStateExists,
recordLegacySyncPath,
rollbackLegacyCodexSync,
stripMarkerBlock,
+4 -3
View File
@@ -135,8 +135,9 @@ const HARNESS_CAPABILITIES = deepFreeze([
installMode: 'managed-home',
guidedReady: false,
availability: 'advanced',
destination: '~/.opencode',
scopes: [scope('home', 'opencode', '~/.opencode')],
destination: '~/.config/opencode',
destinationResolution: 'OPENCODE_CONFIG_DIR, then XDG_CONFIG_HOME/opencode, then ~/.config/opencode',
scopes: [scope('home', 'opencode', '~/.config/opencode')],
hooks: hooks(
'adapter-opt-in',
false,
@@ -249,7 +250,7 @@ for (const harness of HARNESS_CAPABILITIES) {
function expectedRootForAdapter(adapter) {
const homeDir = path.resolve('/__ecc_catalog_home__');
const projectRoot = path.resolve('/__ecc_catalog_project__');
const absoluteRoot = adapter.resolveRoot({ homeDir, projectRoot });
const absoluteRoot = adapter.resolveRoot({ homeDir, projectRoot, env: {} });
const baseRoot = adapter.kind === 'home' ? homeDir : projectRoot;
const prefix = adapter.kind === 'home' ? '~/' : './';
return `${prefix}${path.relative(baseRoot, absoluteRoot).replace(/\\/g, '/')}`;
+10 -1
View File
@@ -7,6 +7,7 @@ const { toCursorAgentRelativePath } = require('./cursor-agent-names');
const { LEGACY_INSTALL_TARGETS, parseInstallArgs } = require('./install/request');
const { SUPPORTED_INSTALL_TARGETS, listLegacyCompatibilityLanguages, resolveLegacyCompatibilitySelection, resolveInstallPlan } = require('./install-manifests');
const { getInstallTargetAdapter } = require('./install-targets/registry');
const { resolveInvocationEnvironment } = require('./invocation-environment');
const LANGUAGE_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
const CLAUDE_ECC_NAMESPACE = 'ecc';
@@ -80,7 +81,12 @@ function validateLegacyTarget(target) {
throw new Error(`Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}`);
}
const IGNORED_DIRECTORY_NAMES = new Set(['node_modules', '.git', '__pycache__']);
const IGNORED_DIRECTORY_NAMES = new Set([
'node_modules',
'.git',
'__pycache__',
'.pytest_cache',
]);
const IGNORED_FILE_EXTENSIONS = new Set(['.pyc', '.pyo', '.pyd']);
function listFilesRecursive(dirPath) {
@@ -640,6 +646,7 @@ function createLegacyCompatInstallPlan(options = {}) {
sourceRoot,
projectRoot,
homeDir: options.homeDir,
env: resolveInvocationEnvironment(options),
target,
profileId: null,
moduleIds: selection.moduleIds,
@@ -769,6 +776,7 @@ function createManifestInstallPlan(options = {}) {
repoRoot: sourceRoot,
projectRoot,
homeDir: options.homeDir,
env: resolveInvocationEnvironment(options),
profileId: options.profileId || null,
moduleIds: options.moduleIds || [],
includeComponentIds: options.includeComponentIds || [],
@@ -822,6 +830,7 @@ function createManifestInstallPlan(options = {}) {
target: adapter.target,
kind: adapter.kind
},
homeDir: plan.homeDir,
targetRoot: plan.targetRoot,
installRoot: plan.targetRoot,
installStatePath: plan.installStatePath,
+144 -16
View File
@@ -15,9 +15,14 @@ const {
getLegacyAntigravityLocation,
inspectLegacyAntigravityState,
} = require('./install/antigravity-legacy-migration');
const {
getLegacyOpencodeLocation,
inspectLegacyOpencodeState,
} = require('./install/opencode-legacy-migration');
const { adaptAntigravityAgent } = require('./install/antigravity-agent');
const { buildInstallIndex, rewriteRelativeLinks } = require('./install/link-rewrite');
const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry');
const { resolveInvocationEnvironment } = require('./invocation-environment');
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';
@@ -68,6 +73,7 @@ function getOpencodeBuildValidationIssues(context) {
return getInstallTargetAdapter('opencode').validate({
homeDir: context.homeDir,
repoRoot: context.repoRoot,
env: context.env,
});
}
@@ -1187,7 +1193,8 @@ function buildDiscoveryRecord(adapter, context, location = null, knownState = nu
const installTargetInput = {
homeDir: context.homeDir,
projectRoot: context.projectRoot,
repoRoot: context.projectRoot
repoRoot: context.projectRoot,
env: context.env,
};
const targetRoot = location
? location.targetRoot
@@ -1209,7 +1216,8 @@ function buildDiscoveryRecord(adapter, context, location = null, knownState = nu
exists: false,
state: null,
error: null,
legacy: Boolean(location)
legacy: Boolean(location),
legacyLayout: location?.legacyLayout || null
};
}
@@ -1225,7 +1233,8 @@ function buildDiscoveryRecord(adapter, context, location = null, knownState = nu
exists: true,
state: knownState,
error: null,
legacy: Boolean(location)
legacy: Boolean(location),
legacyLayout: location?.legacyLayout || null
};
}
@@ -1242,7 +1251,8 @@ function buildDiscoveryRecord(adapter, context, location = null, knownState = nu
exists: true,
state,
error: null,
legacy: Boolean(location)
legacy: Boolean(location),
legacyLayout: location?.legacyLayout || null
};
} catch (error) {
return {
@@ -1256,7 +1266,8 @@ function buildDiscoveryRecord(adapter, context, location = null, knownState = nu
exists: true,
state: null,
error: error.message,
legacy: Boolean(location)
legacy: Boolean(location),
legacyLayout: location?.legacyLayout || null
};
}
}
@@ -1264,18 +1275,54 @@ function buildDiscoveryRecord(adapter, context, location = null, knownState = nu
function discoverInstalledStates(options = {}) {
const context = {
homeDir: options.homeDir || process.env.HOME || os.homedir(),
projectRoot: options.projectRoot || process.cwd()
projectRoot: options.projectRoot || process.cwd(),
env: resolveInvocationEnvironment(options),
};
const targets = normalizeTargets(options.targets);
return targets.flatMap(target => {
const adapter = getInstallTargetAdapter(target);
const canonicalRecord = buildDiscoveryRecord(adapter, context);
if (adapter.target === 'opencode') {
const legacyLocation = getLegacyOpencodeLocation(context.homeDir);
const legacyInspection = inspectLegacyOpencodeState(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,
legacyLayout: 'opencode',
}];
}
return [
canonicalRecord,
buildDiscoveryRecord(adapter, context, legacyLocation, legacyInspection.state),
];
}
if (adapter.target !== 'antigravity') {
return [canonicalRecord];
}
const legacyLocation = getLegacyAntigravityLocation(context.projectRoot);
const legacyLocation = {
...getLegacyAntigravityLocation(context.projectRoot),
legacyLayout: 'antigravity',
};
const legacyInspection = inspectLegacyAntigravityState(legacyLocation);
if (
path.resolve(legacyLocation.installStatePath) === path.resolve(canonicalRecord.installStatePath)
@@ -1296,8 +1343,9 @@ function discoverInstalledStates(options = {}) {
installStatePath: legacyLocation.installStatePath,
exists: true,
state: null,
error: legacyInspection.error,
legacy: true,
error: legacyInspection.error,
legacy: true,
legacyLayout: 'antigravity',
}];
}
@@ -1332,7 +1380,7 @@ function determineStatus(issues) {
function analyzeRecord(record, context) {
const issues = [];
if (record.legacy) {
if (record.legacyLayout === 'antigravity') {
issues.push(buildIssue(
'warning',
'legacy-antigravity-layout',
@@ -1340,6 +1388,14 @@ function analyzeRecord(record, context) {
));
}
if (record.legacyLayout === 'opencode') {
issues.push(buildIssue(
'warning',
'legacy-opencode-layout',
'Legacy OpenCode install-state remains under ~/.opencode. Rerun the OpenCode install or repair command to migrate unchanged ECC-managed files to ~/.config/opencode; modified files are preserved for review.'
));
}
if (record.error) {
issues.push(buildIssue('error', 'invalid-install-state', record.error));
return {
@@ -1454,6 +1510,7 @@ function analyzeRecord(record, context) {
repoRoot: context.repoRoot,
projectRoot: context.projectRoot,
homeDir: context.homeDir,
env: context.env,
target: record.adapter.target,
profileId: state.request.profile || null,
moduleIds: state.request.modules || [],
@@ -1489,12 +1546,14 @@ function buildDoctorReport(options = {}) {
const records = discoverInstalledStates({
homeDir: options.homeDir,
projectRoot: options.projectRoot,
targets: options.targets
targets: options.targets,
env: resolveInvocationEnvironment(options),
}).filter(record => record.exists);
const context = {
repoRoot,
homeDir: options.homeDir || process.env.HOME || os.homedir(),
projectRoot: options.projectRoot || process.cwd(),
env: resolveInvocationEnvironment(options),
manifestVersion: manifests.modulesVersion,
packageVersion: readPackageVersion(repoRoot)
};
@@ -1534,7 +1593,10 @@ function createRepairPlanFromRecord(record, context, options = {}) {
throw new Error('No install-state available for repair');
}
if (state.request.legacyMode || shouldRepairFromRecordedOperations(state)) {
if (
record.legacyLayout !== 'opencode'
&& (state.request.legacyMode || shouldRepairFromRecordedOperations(state))
) {
const operations = hydrateRecordedOperations(context.repoRoot, getManagedOperations(state));
const statePreview = buildRecordedStatePreview(state, context, operations);
@@ -1561,6 +1623,7 @@ function createRepairPlanFromRecord(record, context, options = {}) {
excludeComponentIds: state.request.excludeComponents || [],
projectRoot: context.projectRoot,
homeDir: context.homeDir,
env: context.env,
exemptValidationCodes: options.exemptValidationCodes || [],
});
@@ -1659,6 +1722,7 @@ function repairInstalledStates(options = {}) {
repoRoot,
homeDir: options.homeDir || process.env.HOME || os.homedir(),
projectRoot: options.projectRoot || process.cwd(),
env: resolveInvocationEnvironment(options),
manifestVersion: manifests.modulesVersion,
packageVersion: readPackageVersion(repoRoot)
};
@@ -1668,8 +1732,12 @@ function repairInstalledStates(options = {}) {
const records = discoverInstalledStates({
homeDir: context.homeDir,
projectRoot: context.projectRoot,
targets: options.targets
}).filter(record => record.exists && !record.legacy);
targets: options.targets,
env: context.env,
}).filter(record => (
record.exists
&& (!record.legacy || record.legacyLayout === 'opencode')
));
const results = records.map(record => {
if (record.error) {
@@ -1688,6 +1756,65 @@ function repairInstalledStates(options = {}) {
&& hasOpencodeBuildError(getOpencodeBuildValidationIssues(context));
const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT);
if (record.legacyLayout === 'opencode') {
if (needsOpencodeBuild && !options.dryRun) {
try {
buildOpencodeRunner(context.repoRoot);
} catch (error) {
return {
adapter: record.adapter,
status: 'error',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs: [],
error: formatBuildErrorMessage(error),
};
}
}
const canonicalPlan = createRepairPlanFromRecord(record, context, {
exemptValidationCodes: options.dryRun && needsOpencodeBuild
? [OPENCODE_PLUGIN_NOT_BUILT_CODE]
: [],
});
const plannedRepairs = [...new Set([
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
...canonicalPlan.operations.map(operation => operation.destinationPath),
...getManagedOperations(record.state).map(operation => operation.destinationPath),
record.installStatePath,
])];
if (options.dryRun) {
return {
adapter: record.adapter,
status: 'planned',
installStatePath: canonicalPlan.installStatePath,
repairedPaths: [],
plannedRepairs,
stateRefreshed: false,
warnings: canonicalPlan.warnings,
error: null,
};
}
// Load lazily to avoid a module cycle during install-lifecycle startup.
const { applyInstallPlan } = require('./install/apply');
const appliedPlan = applyInstallPlan(canonicalPlan);
return {
adapter: record.adapter,
status: 'repaired',
installStatePath: canonicalPlan.installStatePath,
repairedPaths: [
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
...canonicalPlan.operations.map(operation => operation.destinationPath),
],
plannedRepairs: [],
stateRefreshed: true,
warnings: appliedPlan.warnings,
error: null,
};
}
if (needsOpencodeBuild && options.dryRun) {
const rawPlan = createRepairPlanFromRecord(record, context, {
exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE],
@@ -1921,7 +2048,8 @@ function uninstallInstalledStates(options = {}) {
const records = discoverInstalledStates({
homeDir: options.homeDir,
projectRoot: options.projectRoot,
targets: options.targets
targets: options.targets,
env: resolveInvocationEnvironment(options),
}).filter(record => record.exists);
const results = records.map(record => {
@@ -1938,7 +2066,7 @@ function uninstallInstalledStates(options = {}) {
const state = record.state;
const managedOperations = getManagedOperations(state);
if (record.legacy && managedOperations.length > 0) {
if (record.legacyLayout === 'antigravity' && managedOperations.length > 0) {
return {
adapter: record.adapter,
status: 'partial',
+4
View File
@@ -2,6 +2,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const { getInstallTargetAdapter, planInstallTargetScaffold } = require('./install-targets/registry');
const { resolveInvocationEnvironment } = require('./invocation-environment');
const DEFAULT_REPO_ROOT = path.join(__dirname, '../..');
const SUPPORTED_INSTALL_TARGETS = ['claude', 'claude-project', 'cursor', 'antigravity', 'codex', 'gemini', 'opencode', 'codebuddy', 'joycode', 'qwen', 'zed', 'hermes', 'openclaw', 'kimi'];
@@ -595,6 +596,7 @@ function resolveInstallPlan(options = {}) {
repoRoot: manifests.repoRoot,
projectRoot: validatedProjectRoot || manifests.repoRoot,
homeDir: validatedHomeDir || os.homedir(),
env: resolveInvocationEnvironment(options),
}
: null;
const targetAdapter = target ? getInstallTargetAdapter(target) : null;
@@ -693,6 +695,7 @@ function resolveInstallPlan(options = {}) {
repoRoot: targetPlanningInput.repoRoot,
projectRoot: targetPlanningInput.projectRoot,
homeDir: targetPlanningInput.homeDir,
env: targetPlanningInput.env,
modules: selectedModules,
exemptValidationCodes: options.exemptValidationCodes || [],
})
@@ -719,6 +722,7 @@ function resolveInstallPlan(options = {}) {
skippedModules,
excludedModules,
targetAdapterId: scaffoldPlan ? scaffoldPlan.adapter.id : null,
homeDir: targetPlanningInput ? targetPlanningInput.homeDir : null,
targetRoot: scaffoldPlan ? scaffoldPlan.targetRoot : null,
installStatePath: scaffoldPlan ? scaffoldPlan.installStatePath : null,
operations: scaffoldPlan ? scaffoldPlan.operations : [],
+1
View File
@@ -51,6 +51,7 @@ async function reconcileCanonicalInstallStates(options = {}) {
homeDir: options.homeDir,
projectRoot: options.projectRoot,
targets: options.targets,
env: options.env,
discoverInstalledStates: options.discoverInstalledStates,
}));
}
+3
View File
@@ -264,6 +264,9 @@ function createInstallTargetAdapter(config) {
},
resolveRoot(input = {}) {
const baseRoot = resolveBaseRoot(config.kind, input);
if (typeof config.resolveRoot === 'function') {
return config.resolveRoot(input, baseRoot);
}
return path.join(baseRoot, ...config.rootSegments);
},
getInstallStatePath(input = {}) {
+3 -1
View File
@@ -6,6 +6,7 @@ const {
buildValidationIssue,
createInstallTargetAdapter,
} = require('./helpers');
const { resolveOpencodeConfigRoot } = require('../opencode-paths');
const COMPILED_PLUGIN_DIST_DIR = path.join('.opencode', 'dist');
const REQUIRED_COMPILED_ARTEFACTS = Object.freeze([
@@ -83,7 +84,8 @@ module.exports = createInstallTargetAdapter({
id: 'opencode-home',
target: 'opencode',
kind: 'home',
rootSegments: ['.opencode'],
rootSegments: ['.config', 'opencode'],
resolveRoot: resolveOpencodeConfigRoot,
installStatePathSegments: ['ecc-install-state.json'],
nativeRootRelativePath: '.opencode',
validate: defaultValidateOpencodeHome,
+2
View File
@@ -12,6 +12,7 @@ const openclawHome = require('./openclaw-home');
const opencodeHome = require('./opencode-home');
const qwenHome = require('./qwen-home');
const zedProject = require('./zed-project');
const { resolveInvocationEnvironment } = require('../invocation-environment');
const ADAPTERS = Object.freeze([
claudeHome,
@@ -52,6 +53,7 @@ function planInstallTargetScaffold(options = {}) {
repoRoot: options.repoRoot,
projectRoot: options.projectRoot || options.repoRoot,
homeDir: options.homeDir,
env: resolveInvocationEnvironment(options),
};
const validationIssues = adapter.validate(planningInput);
const blockingIssues = validationIssues.filter(issue => (
+34
View File
@@ -17,6 +17,7 @@ const {
removeLegacyClaudeSkillFiles,
} = require('./claude-skill-migration');
const { cleanupLegacyAntigravityInstall } = require('./antigravity-legacy-migration');
const { cleanupLegacyOpencodeInstall } = require('./opencode-legacy-migration');
const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite');
const { adaptAntigravityAgent } = require('./antigravity-agent');
@@ -119,12 +120,25 @@ function readInstalledFileNoFollow(plan, operation) {
}
function stateWithContentDigests(state, plan) {
const currentDestinations = new Set((plan.operations || [])
.filter(operation => operation.destinationPath)
.map(operation => {
const resolved = path.resolve(operation.destinationPath);
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
}));
return {
...state,
operations: (state.operations || []).map(operation => {
if (!operation.destinationPath) {
return { ...operation };
}
const resolved = path.resolve(operation.destinationPath);
const destinationKey = process.platform === 'win32'
? resolved.toLowerCase()
: resolved;
if (!currentDestinations.has(destinationKey)) {
return { ...operation };
}
const installedContent = readInstalledFileNoFollow(plan, operation);
if (installedContent === null) {
return { ...operation };
@@ -337,8 +351,12 @@ function previewInstallPlan(plan) {
function applyInstallPlan(plan, dependencies = {}) {
const persistInstallState = dependencies.writeInstallState || writeInstallState;
const beforeInstallStateRead = dependencies.beforeInstallStateRead;
const beforeOperationWrite = dependencies.beforeOperationWrite;
const beforeInstallStateWrite = dependencies.beforeInstallStateWrite;
if (typeof beforeInstallStateRead === 'function') {
beforeInstallStateRead({ plan });
}
const migration = prepareClaudeSkillMigration(plan);
const appliedPlan = {
...plan,
@@ -489,6 +507,21 @@ function applyInstallPlan(plan, dependencies = {}) {
];
}
let opencodeMigrationWarnings = [];
try {
const opencodeMigration = cleanupLegacyOpencodeInstall(appliedPlan);
if (opencodeMigration.detected && !opencodeMigration.complete) {
opencodeMigrationWarnings = [
'Legacy OpenCode migration is incomplete. ECC preserved modified or unverifiable managed content under ~/.opencode; review it and rerun the OpenCode install.',
...(Array.isArray(opencodeMigration.warnings) ? opencodeMigration.warnings : []),
];
}
} catch (error) {
opencodeMigrationWarnings = [
`Legacy OpenCode cleanup did not finish: ${error.message}. Content under ~/.opencode was preserved; rerun the OpenCode install or review it manually.`,
];
}
return {
...plan,
statePreview: finalState,
@@ -499,6 +532,7 @@ function applyInstallPlan(plan, dependencies = {}) {
...(Array.isArray(plan.warnings) ? plan.warnings : []),
...migration.warnings,
...antigravityMigrationWarnings,
...opencodeMigrationWarnings,
],
applied: true,
};
+25 -23
View File
@@ -133,19 +133,13 @@ function isManagedOperation(operation) {
}
function uniqueOperations(operations) {
const seen = new Set();
return operations.filter(operation => {
const key = [
operation.kind,
normalizeSourceRelativePath(operation.sourceRelativePath) || operation.sourceRelativePath,
comparablePath(operation.destinationPath),
].join('\0');
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
const byDestination = new Map();
for (const operation of operations) {
// A target path has one current owner. Later operations come from the
// newest plan and replace stale metadata for the same destination.
byDestination.set(comparablePath(operation.destinationPath), operation);
}
return [...byDestination.values()];
}
function buildState(statePreview, operations) {
@@ -236,16 +230,20 @@ function createFileConflictWarning(destinationPath, retainsLegacy) {
return `Skipped user-owned Claude skill file ${destinationPath}: the existing file is not recorded in ECC install-state.${legacySuffix}`;
}
function createDisabledMigration(plan) {
function createDisabledMigration(plan, previousState) {
const finalState = buildState(plan.statePreview, [
...((previousState && previousState.operations) || []),
...plan.statePreview.operations,
]);
return {
enabled: false,
appliedOperations: [...plan.operations],
skippedOperations: [],
warnings: [],
bridgeState: plan.statePreview,
finalState: plan.statePreview,
bridgeState: finalState,
finalState,
legacyOperationsToRemove: [],
requiresBridgeState: false,
requiresBridgeState: plan.operations.length > 0,
};
}
@@ -331,11 +329,16 @@ function buildMigrationStates(plan, previousState, previous, classification) {
const legacyOperationsToRemove = legacyOperations.filter(operation => (
!retainedLegacyOperations.has(operation)
));
const removedLegacyDestinations = new Set(
legacyOperationsToRemove.map(operation => comparablePath(operation.destinationPath))
);
const finalOperations = [
...((previousState && previousState.operations) || []).filter(operation => (
!removedLegacyDestinations.has(comparablePath(operation.destinationPath))
)),
...plan.statePreview.operations.filter(operation => (
!skippedDestinations.has(comparablePath(operation.destinationPath))
)),
...retainedLegacyOperations,
];
const bridgeOperations = [
...((previousState && previousState.operations) || []),
@@ -352,14 +355,13 @@ function buildMigrationStates(plan, previousState, previous, classification) {
}
function prepareClaudeSkillMigration(plan) {
const target = plan && plan.adapter && plan.adapter.target;
if (!CLAUDE_TARGETS.has(target)) {
return createDisabledMigration(plan);
}
const previousState = pathExists(plan.installStatePath)
? readInstallState(plan.installStatePath)
: null;
const target = plan && plan.adapter && plan.adapter.target;
if (!CLAUDE_TARGETS.has(target)) {
return createDisabledMigration(plan, previousState);
}
const currentGroups = groupCurrentSkillOperations(plan);
const previous = classifyPreviousOperations(plan, previousState);
const classification = classifySkillConflicts(currentGroups, previous);
@@ -0,0 +1,398 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { readInstallState } = require('../install-state');
const { assertWithinTrustedRoot } = require('../path-safety');
const OPENCODE_TARGET = 'opencode';
const INSTALL_STATE_NAME = 'ecc-install-state.json';
function samePath(leftPath, rightPath) {
const left = path.resolve(leftPath);
const right = path.resolve(rightPath);
return process.platform === 'win32'
? left.toLowerCase() === right.toLowerCase()
: left === right;
}
function pathExists(filePath) {
try {
fs.lstatSync(filePath);
return true;
} catch (error) {
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
return false;
}
throw error;
}
}
function getLegacyOpencodeLocation(homeDir) {
const targetRoot = path.join(path.resolve(homeDir), '.opencode');
return {
targetRoot,
installStatePath: path.join(targetRoot, INSTALL_STATE_NAME),
legacyLayout: 'opencode',
};
}
function getLegacyLocationForPlan(plan) {
if (
!plan
|| plan.adapter?.target !== OPENCODE_TARGET
|| typeof plan.targetRoot !== 'string'
) {
return null;
}
if (typeof plan.homeDir === 'string' && plan.homeDir.trim() !== '') {
return getLegacyOpencodeLocation(plan.homeDir);
}
const canonicalRoot = path.resolve(plan.targetRoot);
if (
path.basename(canonicalRoot) !== 'opencode'
|| path.basename(path.dirname(canonicalRoot)) !== '.config'
) {
return null;
}
return getLegacyOpencodeLocation(path.dirname(path.dirname(canonicalRoot)));
}
function inspectLegacyOpencodeState(location) {
if (!location) {
return { status: 'absent', state: null, error: null };
}
try {
if (!pathExists(location.installStatePath)) {
return { status: 'absent', state: null, error: null };
}
const rootStat = fs.lstatSync(location.targetRoot);
const stateStat = fs.lstatSync(location.installStatePath);
if (
!rootStat.isDirectory()
|| rootStat.isSymbolicLink()
|| !stateStat.isFile()
|| stateStat.isSymbolicLink()
) {
return { status: 'invalid', state: null, error: null };
}
const state = readInstallState(location.installStatePath);
const isOpencode = state.target.target === OPENCODE_TARGET
|| state.target.id === 'opencode-home';
if (
!isOpencode
|| !samePath(state.target.root, location.targetRoot)
|| !samePath(state.target.installStatePath, location.installStatePath)
) {
return { status: 'invalid', state: null, error: null };
}
return { status: 'valid', state, error: null };
} catch (error) {
return {
status: 'unreadable',
state: null,
error: `Unable to inspect legacy OpenCode install-state at ${location.installStatePath}: ${error.message}`,
};
}
}
function hashFileNoFollow(filePath) {
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
const descriptor = fs.openSync(filePath, flags);
try {
const before = fs.fstatSync(descriptor, { bigint: true });
if (!before.isFile()) {
throw new Error(`Refusing to read a non-file at ${filePath}`);
}
const content = fs.readFileSync(descriptor);
const after = fs.fstatSync(descriptor, { bigint: true });
const finalPathStat = fs.lstatSync(filePath, { bigint: true });
const unchanged = before.dev === after.dev
&& before.ino === after.ino
&& before.size === after.size
&& before.mtimeMs === after.mtimeMs
&& before.ctimeMs === after.ctimeMs
&& after.dev === finalPathStat.dev
&& after.ino === finalPathStat.ino
&& after.size === finalPathStat.size
&& after.mtimeMs === finalPathStat.mtimeMs
&& after.ctimeMs === finalPathStat.ctimeMs;
if (finalPathStat.isSymbolicLink() || !finalPathStat.isFile() || !unchanged) {
throw new Error(`Refusing to read a file that changed during validation: ${filePath}`);
}
return {
digest: crypto.createHash('sha256').update(content).digest('hex'),
stat: after,
};
} finally {
fs.closeSync(descriptor);
}
}
function removeEmptyParents(startPath, legacyRoot) {
let currentPath = path.dirname(startPath);
while (!samePath(currentPath, legacyRoot)) {
const safePath = assertWithinTrustedRoot(
currentPath,
legacyRoot,
'clean legacy OpenCode install'
);
if (!pathExists(safePath)) {
currentPath = path.dirname(safePath);
continue;
}
const stat = fs.lstatSync(safePath);
if (!stat.isDirectory() || stat.isSymbolicLink() || fs.readdirSync(safePath).length > 0) {
return;
}
fs.rmdirSync(safePath);
currentPath = path.dirname(safePath);
}
}
function verifyManagedLegacyFile(operation, location, sourceRoot) {
if (operation?.ownership !== 'managed' || operation?.kind !== 'copy-file') {
return { skipped: true };
}
if (
typeof operation.destinationPath !== 'string'
|| typeof operation.sourceRelativePath !== 'string'
|| !/^[a-f0-9]{64}$/i.test(operation.contentSha256 || '')
) {
return { retainedPath: operation?.destinationPath || location.targetRoot };
}
let destinationPath;
let sourcePath;
try {
destinationPath = assertWithinTrustedRoot(
operation.destinationPath,
location.targetRoot,
'migrate legacy OpenCode install'
);
sourcePath = assertWithinTrustedRoot(
path.join(sourceRoot, operation.sourceRelativePath),
sourceRoot,
'verify legacy OpenCode source'
);
} catch (_error) {
return { retainedPath: operation.destinationPath };
}
let destination;
try {
destination = hashFileNoFollow(destinationPath);
} catch (error) {
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
return { missing: true };
}
return { retainedPath: destinationPath };
}
if (destination.digest !== operation.contentSha256.toLowerCase()) {
return { retainedPath: destinationPath };
}
let source;
try {
source = hashFileNoFollow(sourcePath);
} catch (_error) {
return { retainedPath: destinationPath };
}
if (source.digest !== destination.digest) {
return { retainedPath: destinationPath };
}
return { destinationPath, stat: destination.stat };
}
function pathExistsWith(fileSystem, filePath) {
try {
fileSystem.lstatSync(filePath);
return true;
} catch (error) {
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
return false;
}
throw error;
}
}
function restoreQuarantinedFileNoClobber(quarantinePath, safePath, fileSystem) {
try {
fileSystem.linkSync(quarantinePath, safePath);
} catch (error) {
error.retainedPath = quarantinePath;
throw error;
}
try {
fileSystem.rmSync(quarantinePath);
} catch (error) {
error.retainedPath = quarantinePath;
throw error;
}
}
function removeVerifiedLegacyFile(entry, location, fileSystem = fs) {
const safePath = assertWithinTrustedRoot(
entry.destinationPath,
location.targetRoot,
'remove verified legacy OpenCode file'
);
const quarantineDir = fileSystem.mkdtempSync(path.join(
path.dirname(location.targetRoot),
'.ecc-opencode-remove-'
));
const quarantinePath = path.join(quarantineDir, path.basename(safePath));
try {
fileSystem.renameSync(safePath, quarantinePath);
const quarantinedStat = fileSystem.lstatSync(quarantinePath, { bigint: true });
const identityMatches = !quarantinedStat.isSymbolicLink()
&& quarantinedStat.isFile()
&& quarantinedStat.dev === entry.stat.dev
&& quarantinedStat.ino === entry.stat.ino;
if (!identityMatches) {
const identityError = new Error(
`Legacy OpenCode file changed during quarantine: ${safePath}`
);
identityError.code = 'ESTALE';
throw identityError;
}
fileSystem.rmSync(quarantinePath);
fileSystem.rmdirSync(quarantineDir);
return true;
} catch (error) {
let restoreError = null;
try {
if (pathExistsWith(fileSystem, quarantinePath)) {
restoreQuarantinedFileNoClobber(quarantinePath, safePath, fileSystem);
}
if (
pathExistsWith(fileSystem, quarantineDir)
&& fileSystem.readdirSync(quarantineDir).length === 0
) {
fileSystem.rmdirSync(quarantineDir);
}
} catch (recoveryError) {
restoreError = recoveryError;
}
if (restoreError) {
restoreError.cause = error;
throw restoreError;
}
throw error;
}
}
function emptyCleanupResult() {
return {
detected: false,
complete: false,
removedPaths: [],
retainedPaths: [],
warnings: [],
};
}
function hasTrustedCanonicalState(plan) {
if (typeof plan.sourceRoot !== 'string' || !pathExists(plan.installStatePath)) {
return false;
}
try {
const canonicalState = readInstallState(plan.installStatePath);
return !(
(canonicalState.target.target !== OPENCODE_TARGET
&& canonicalState.target.id !== 'opencode-home')
|| !samePath(canonicalState.target.root, plan.targetRoot)
|| !samePath(canonicalState.target.installStatePath, plan.installStatePath)
);
} catch (_error) {
return false;
}
}
function classifyLegacyOperations(inspection, location, sourceRoot) {
const removable = [];
const retainedPaths = [];
for (const operation of inspection.state.operations || []) {
const verified = verifyManagedLegacyFile(operation, location, sourceRoot);
if (verified.destinationPath) removable.push(verified);
else if (verified.retainedPath) retainedPaths.push(verified.retainedPath);
}
return { removable, retainedPaths };
}
function removeLegacyFiles(removable, location, retainedPaths) {
const removedPaths = [];
for (const entry of removable) {
try {
if (!removeVerifiedLegacyFile(entry, location)) {
retainedPaths.push(entry.destinationPath);
continue;
}
removedPaths.push(entry.destinationPath);
removeEmptyParents(entry.destinationPath, location.targetRoot);
} catch (error) {
retainedPaths.push(entry.destinationPath);
if (error.retainedPath) retainedPaths.push(error.retainedPath);
}
}
return removedPaths;
}
function finalizeLegacyCleanup(location, retainedPaths, removedPaths) {
if (retainedPaths.length > 0) return false;
fs.rmSync(location.installStatePath, { force: true });
removedPaths.push(location.installStatePath);
try {
if (pathExists(location.targetRoot) && fs.readdirSync(location.targetRoot).length === 0) {
fs.rmdirSync(location.targetRoot);
}
} catch (_error) {
// Removing an empty legacy root is best effort after ownership is cleared.
}
return true;
}
function cleanupLegacyOpencodeInstall(plan) {
const location = getLegacyLocationForPlan(plan);
const emptyResult = emptyCleanupResult();
if (!location || !hasTrustedCanonicalState(plan)) return emptyResult;
const inspection = inspectLegacyOpencodeState(location);
if (inspection.status === 'unreadable') {
return {
...emptyResult,
detected: true,
retainedPaths: [location.targetRoot],
warnings: [inspection.error],
};
}
if (inspection.status !== 'valid') {
return emptyResult;
}
const { removable, retainedPaths } = classifyLegacyOperations(
inspection,
location,
plan.sourceRoot
);
const removedPaths = removeLegacyFiles(removable, location, retainedPaths);
const complete = finalizeLegacyCleanup(location, retainedPaths, removedPaths);
return {
detected: true,
complete,
removedPaths,
retainedPaths: [...new Set(retainedPaths)].sort(),
warnings: complete
? []
: ['Modified, unsupported, or unverifiable managed files remain under ~/.opencode and were preserved.'],
};
}
module.exports = {
cleanupLegacyOpencodeInstall,
getLegacyOpencodeLocation,
inspectLegacyOpencodeState,
removeVerifiedLegacyFile,
};
+3
View File
@@ -5,6 +5,7 @@ const {
createLegacyInstallPlan,
createManifestInstallPlan,
} = require('../install-executor');
const { resolveInvocationEnvironment } = require('../invocation-environment');
function createInstallPlanFromRequest(request, options = {}) {
if (!request || typeof request !== 'object') {
@@ -20,6 +21,7 @@ function createInstallPlanFromRequest(request, options = {}) {
excludeComponentIds: request.excludeComponentIds,
projectRoot: options.projectRoot,
homeDir: options.homeDir,
env: resolveInvocationEnvironment(options),
sourceRoot: options.sourceRoot,
});
}
@@ -32,6 +34,7 @@ function createInstallPlanFromRequest(request, options = {}) {
excludeComponentIds: request.excludeComponentIds,
projectRoot: options.projectRoot,
homeDir: options.homeDir,
env: resolveInvocationEnvironment(options),
claudeRulesDir: options.claudeRulesDir,
sourceRoot: options.sourceRoot,
});
+17
View File
@@ -0,0 +1,17 @@
'use strict';
function resolveInvocationEnvironment(options = {}) {
if (Object.prototype.hasOwnProperty.call(options, 'env')) {
return { ...(options.env || {}) };
}
if (typeof options.homeDir === 'string' && options.homeDir.trim() !== '') {
return {};
}
return { ...process.env };
}
module.exports = {
resolveInvocationEnvironment,
};
+2 -1
View File
@@ -156,7 +156,8 @@ function generateSessionSummary(transcriptPath) {
env: {
...process.env,
CLAUDECODE: '',
ECC_SKIP_LLM_SUMMARY: '1'
ECC_SKIP_LLM_SUMMARY: '1',
ECC_LLM_SUMMARY_SUBPROCESS: '1'
},
timeout: LLM_TIMEOUT_MS,
shell: process.platform === 'win32'
@@ -3,8 +3,10 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { resolveOpencodeConfigRoot } = require('../../opencode-paths');
const { resolveInvocationEnvironment } = require('../../invocation-environment');
// OpenCode stores MCP servers under "mcp" in ~/.config/opencode/opencode.json.
// OpenCode stores MCP servers under "mcp" in its resolved configuration root.
// Shape differs from Claude/Codex:
// { type: "local"|"remote", command: ["npx","-y","pkg"], environment: {},
// enabled: bool, url: "https://..." }
@@ -38,11 +40,15 @@ function mapOpencodeServer(name, raw, configPath) {
function readOpencodeMcp(options = {}) {
const homeDir = options.homeDir || os.homedir();
const configRoot = resolveOpencodeConfigRoot({
homeDir,
env: resolveInvocationEnvironment(options),
});
const candidatePaths = options.configPath
? [options.configPath]
: [
path.join(homeDir, '.config', 'opencode', 'opencode.json'),
path.join(homeDir, '.config', 'opencode', 'config.json'),
path.join(configRoot, 'opencode.json'),
path.join(configRoot, 'config.json'),
path.join(homeDir, '.opencode.json')
];
+71 -14
View File
@@ -64,11 +64,60 @@ function pathsMatch(left, right) {
return canonicalPath(left) === canonicalPath(right);
}
function sameFileIdentity(left, right) {
return left.dev === right.dev
&& left.ino === right.ino
&& left.size === right.size
&& left.mtimeMs === right.mtimeMs
&& left.ctimeMs === right.ctimeMs;
}
function readRegularFileSnapshot(filePath) {
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
let descriptor;
try {
descriptor = fs.openSync(filePath, flags);
} catch (error) {
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return null;
throw error;
}
try {
const before = fs.fstatSync(descriptor);
if (!before.isFile()) {
throw new Error(`Refusing to read a non-file at ${filePath}.`);
}
const content = fs.readFileSync(descriptor);
const after = fs.fstatSync(descriptor);
const finalPathStat = fs.lstatSync(filePath);
if (
finalPathStat.isSymbolicLink()
|| !finalPathStat.isFile()
|| !sameFileIdentity(before, after)
|| !sameFileIdentity(after, finalPathStat)
) {
throw new Error(`Refusing to read a file that changed during validation: ${filePath}.`);
}
return { content, stat: after };
} finally {
fs.closeSync(descriptor);
}
}
function fingerprintFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, sha256: null };
const snapshot = readRegularFileSnapshot(filePath);
if (!snapshot) return { exists: false, sha256: null };
return {
exists: true,
sha256: crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'),
sha256: crypto.createHash('sha256').update(snapshot.content).digest('hex'),
};
}
function fingerprintInstallStateValue(state) {
const content = Buffer.from(`${JSON.stringify(state, null, 2)}\n`);
return {
exists: true,
sha256: crypto.createHash('sha256').update(content).digest('hex'),
};
}
@@ -131,11 +180,11 @@ function readOwnedDestinations(plan, dependencies) {
} catch (error) {
throw new Error(`Refusing to trust managed install-state path: ${error.message}`);
}
if (!fs.existsSync(plan.installStatePath)) {
const initialFingerprint = fingerprintFile(plan.installStatePath);
if (!initialFingerprint.exists) {
return { destinations: new Set(), stateFingerprint: { exists: false, sha256: null } };
}
const readState = dependencies.readInstallState || require('./install-state').readInstallState;
const initialFingerprint = fingerprintFile(plan.installStatePath);
const state = readState(plan.installStatePath);
const validatedFingerprint = fingerprintFile(plan.installStatePath);
if (
@@ -185,11 +234,12 @@ function readOwnedDestinations(plan, dependencies) {
return { destinations, stateFingerprint: validatedFingerprint };
}
function assertMergeDestination(destinationPath) {
if (!fs.existsSync(destinationPath)) return null;
function assertMergeDestination(destinationPath, existingSnapshot = null) {
const snapshot = existingSnapshot || readRegularFileSnapshot(destinationPath);
if (!snapshot) return null;
let current;
try {
current = JSON.parse(fs.readFileSync(destinationPath, 'utf8'));
current = JSON.parse(snapshot.content.toString('utf8'));
} catch (error) {
throw new Error(`Cannot merge ECC configuration into invalid JSON at ${destinationPath}: ${error.message}`);
}
@@ -218,10 +268,11 @@ function findJsonConflicts(current, patch, prefix = '') {
function classifyManagedOperation(operation, ownedDestinations) {
const destinationPath = operation.destinationPath;
if (!fs.existsSync(destinationPath)) return 'create';
const destination = readRegularFileSnapshot(destinationPath);
if (!destination) return 'create';
const canonicalDestination = canonicalPath(destinationPath);
if (operation.kind === 'merge-json') {
const current = assertMergeDestination(destinationPath);
const current = assertMergeDestination(destinationPath, destination);
if (ownedDestinations.has(canonicalDestination)) return 'managed-json-update';
const conflicts = findJsonConflicts(current, operation.mergePayload);
if (conflicts.length > 0) {
@@ -235,9 +286,7 @@ function classifyManagedOperation(operation, ownedDestinations) {
if (
operation.kind === 'copy-file'
&& typeof operation.sourcePath === 'string'
&& fs.existsSync(operation.sourcePath)
&& fs.statSync(destinationPath).isFile()
&& fs.readFileSync(operation.sourcePath).equals(fs.readFileSync(destinationPath))
&& readRegularFileSnapshot(operation.sourcePath)?.content.equals(destination.content)
) {
return 'identical';
}
@@ -295,6 +344,9 @@ function preflightManagedPlan(plan, dependencies = {}) {
if (!plan || !Array.isArray(plan.operations)) {
throw new Error('A managed install plan with operations is required.');
}
if (typeof plan.installStatePath !== 'string' || plan.installStatePath.length === 0) {
throw new Error('A managed install-state path is required before preflight.');
}
const ownership = readOwnedDestinations(plan, dependencies);
const operations = plan.operations.map(operation => {
assertSafeInstallOperation(plan, operation);
@@ -320,13 +372,18 @@ async function applyPreflightedManagedPlan(entry) {
? entry.preview
: preflightManagedPlan(entry.preview.plan);
const ownedDestinations = new Set(preview.ownershipSnapshot.destinations);
const expectedStateFingerprint = preview.ownershipSnapshot.stateFingerprint;
let expectedStateFingerprint = preview.ownershipSnapshot.stateFingerprint;
let operationIndex = 0;
const assertStateUnchanged = () => (
assertInstallStateUnchanged(preview.plan, expectedStateFingerprint)
);
const prepareInstallStateWrite = ({ state }) => {
assertStateUnchanged();
expectedStateFingerprint = fingerprintInstallStateValue(state);
};
const result = require('./install-executor').applyInstallPlan(preview.plan, {
beforeInstallStateRead: assertStateUnchanged,
beforeOperationWrite({ operation }) {
assertStateUnchanged();
const expected = preview.operations[operationIndex];
@@ -345,7 +402,7 @@ async function applyPreflightedManagedPlan(entry) {
ownedDestinations.add(destination);
operationIndex += 1;
},
beforeInstallStateWrite: assertStateUnchanged,
beforeInstallStateWrite: prepareInstallStateWrite,
});
const { projectCanonicalInstallState } = require('./install-state-store-sync');
const installStateProjection = await projectCanonicalInstallState(result.statePreview);
+148 -15
View File
@@ -77,32 +77,60 @@ function readTarString(block, offset, length) {
return block.subarray(offset, offset + length).toString('utf8').replace(/\0.*$/, '');
}
function readTarOctal(block, offset, length) {
const field = block.subarray(offset, offset + length).toString('ascii');
const match = /^ *([0-7]+)[ \0]*$/.exec(field);
if (!match) throw new Error('Unsafe Nasiko archive: invalid tar size field.');
const size = Number.parseInt(match[1], 8);
if (!Number.isSafeInteger(size) || size < 0) {
throw new Error('Unsafe Nasiko archive: invalid tar size field.');
}
return size;
}
function extractQualifiedTarGzip(archiveBytes, expectedName) {
let tar;
try { tar = zlib.gunzipSync(archiveBytes, { maxOutputLength: MAX_BINARY_BYTES + 2048 }); }
catch (_error) { throw new Error('Nasiko archive is invalid or exceeds the decompressed size limit.'); }
let offset = 0;
let binary = null;
while (offset + 512 <= tar.length) {
let terminated = false;
while (offset < tar.length) {
if (offset + 512 > tar.length) throw new Error('Unsafe Nasiko archive: truncated tar header.');
const header = tar.subarray(offset, offset + 512);
if (header.every(byte => byte === 0)) break;
if (header.every(byte => byte === 0)) {
const terminatorEnd = offset + 1024;
if (
terminatorEnd > tar.length
|| !tar.subarray(offset + 512, terminatorEnd).every(byte => byte === 0)
|| !tar.subarray(terminatorEnd).every(byte => byte === 0)
) {
throw new Error('Unsafe Nasiko archive: incomplete terminator or nonzero trailing data.');
}
terminated = true;
break;
}
const name = readTarString(header, 0, 100);
const prefix = readTarString(header, 345, 155);
const type = String.fromCharCode(header[156] || 48);
const rawSize = readTarString(header, 124, 12).trim();
const size = Number.parseInt(rawSize || '0', 8);
const size = readTarOctal(header, 124, 12);
const start = offset + 512;
const end = start + size;
if (!Number.isSafeInteger(size) || size < 0 || end > tar.length) throw new Error('Nasiko archive is truncated.');
const paddedEnd = start + Math.ceil(size / 512) * 512;
if (!Number.isSafeInteger(end) || paddedEnd > tar.length) throw new Error('Nasiko archive is truncated.');
const payload = tar.subarray(start, end);
if (!tar.subarray(end, paddedEnd).every(byte => byte === 0)) {
throw new Error('Unsafe Nasiko archive: nonzero tar padding.');
}
const isBinary = !prefix && name === expectedName && (type === '0' || type === '\0');
const isAppleDouble = !prefix && name === `._${expectedName}` && type === '0' && size <= 1024 * 1024;
const isPaxMetadata = !prefix && name === `PaxHeader/${expectedName}` && type === 'x' && size <= 64 * 1024
&& !/(?:^|\n)(?:path|linkpath)=/i.test(payload.toString('utf8'));
if (isBinary && !binary && size > 0 && size <= MAX_BINARY_BYTES) binary = Buffer.from(payload);
else if (!isAppleDouble && !isPaxMetadata) throw new Error('Unsafe Nasiko archive: expected exactly one bounded regular binary file.');
offset = start + Math.ceil(size / 512) * 512;
offset = paddedEnd;
}
if (!terminated) throw new Error('Unsafe Nasiko archive: missing complete tar terminator.');
if (!binary) throw new Error('Unsafe Nasiko archive: expected exactly one bounded regular binary file.');
return binary;
}
@@ -229,26 +257,125 @@ function writeMetadataExclusive(metadataPath, metadata) {
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
}
function acquireLifecycleLock(installDirectory, fileSystem = fs) {
const lockPath = path.join(installDirectory, '.ecc-nasiko-lifecycle.lock');
function sameFileIdentity(left, right) {
return left.dev === right.dev && left.ino === right.ino;
}
function processIsAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error.code !== 'ESRCH';
}
}
function inspectLifecycleLock(lockPath, fileSystem) {
const descriptor = fileSystem.openSync(lockPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
try {
const descriptorStats = fileSystem.fstatSync(descriptor, { bigint: true });
if (!descriptorStats.isFile() || descriptorStats.size <= 0n || descriptorStats.size > 4096n) return null;
const bytes = fileSystem.readFileSync(descriptor);
const pathStats = fileSystem.lstatSync(lockPath);
if (pathStats.isSymbolicLink() || !pathStats.isFile()) return null;
let metadata;
try { metadata = JSON.parse(bytes.toString('utf8')); } catch (_error) { return null; }
if (
!Number.isSafeInteger(metadata.pid)
|| metadata.pid <= 0
|| typeof metadata.startedAt !== 'string'
|| !Number.isFinite(Date.parse(metadata.startedAt))
) return null;
return { metadata, stats: descriptorStats };
} finally { fileSystem.closeSync(descriptor); }
}
function removeLockIfOwned(lockPath, expectedStats, fileSystem) {
let descriptor;
try {
descriptor = fileSystem.openSync(lockPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
const current = fileSystem.fstatSync(descriptor, { bigint: true });
const pathStats = fileSystem.lstatSync(lockPath);
if (!pathStats.isSymbolicLink() && pathStats.isFile() && current.isFile() && sameFileIdentity(current, expectedStats)) {
fileSystem.closeSync(descriptor);
descriptor = undefined;
fileSystem.rmSync(lockPath, { force: true });
return true;
}
} catch (error) {
if (error.code !== 'ENOENT' && error.code !== 'ELOOP') throw error;
} finally {
if (descriptor !== undefined) fileSystem.closeSync(descriptor);
}
return false;
}
function createLifecycleLock(lockPath, fileSystem) {
let descriptor;
try {
descriptor = fileSystem.openSync(lockPath, 'wx', 0o600);
fileSystem.writeFileSync(descriptor, `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`);
fileSystem.writeFileSync(descriptor, `${JSON.stringify({
pid: process.pid,
startedAt: new Date().toISOString(),
token: crypto.randomBytes(16).toString('hex'),
})}\n`);
fileSystem.fsyncSync(descriptor);
}
catch (error) {
if (error.code === 'EEXIST') throw new Error(`Another Nasiko lifecycle operation is already in progress; inspect ${lockPath} before recovering a stale lock.`);
if (descriptor !== undefined) {
try { fileSystem.closeSync(descriptor); } finally { fileSystem.rmSync(lockPath, { force: true }); }
const ownedStats = fileSystem.fstatSync(descriptor, { bigint: true });
try { fileSystem.closeSync(descriptor); } finally { removeLockIfOwned(lockPath, ownedStats, fileSystem); }
}
throw error;
}
const ownedStats = fileSystem.fstatSync(descriptor, { bigint: true });
let released = false;
return () => {
try { fileSystem.closeSync(descriptor); } finally { fileSystem.rmSync(lockPath, { force: true }); }
if (released) return;
released = true;
try { fileSystem.closeSync(descriptor); } finally { removeLockIfOwned(lockPath, ownedStats, fileSystem); }
};
}
function acquireLifecycleLock(installDirectory, fileSystem = fs, options = {}) {
const lockPath = path.join(installDirectory, '.ecc-nasiko-lifecycle.lock');
try {
return createLifecycleLock(lockPath, fileSystem);
} catch (error) {
if (error.code !== 'EEXIST') throw error;
}
let existing;
try { existing = inspectLifecycleLock(lockPath, fileSystem); }
catch (error) {
if (error.code === 'ENOENT') {
try { return createLifecycleLock(lockPath, fileSystem); }
catch (retryError) {
if (retryError.code === 'EEXIST') {
throw new Error(`Another Nasiko lifecycle operation won lock acquisition: ${lockPath}.`);
}
throw retryError;
}
}
throw error;
}
const isProcessAlive = options.isProcessAlive || processIsAlive;
if (!existing || isProcessAlive(existing.metadata.pid)) {
throw new Error(`Another Nasiko lifecycle operation is already in progress; inspect ${lockPath} before recovering a stale lock.`);
}
if (!removeLockIfOwned(lockPath, existing.stats, fileSystem)) {
throw new Error(`Nasiko lifecycle lock changed during stale-owner recovery: ${lockPath}.`);
}
try {
return createLifecycleLock(lockPath, fileSystem);
} catch (error) {
if (error.code === 'EEXIST') {
throw new Error(`Another Nasiko lifecycle operation won stale-lock recovery: ${lockPath}.`);
}
throw error;
}
}
async function installNasiko(options = {}, dependencies = {}) {
const version = options.version || 'v0.1.0';
const base = getQualifiedRelease(version, dependencies.platform || process.platform, dependencies.arch || process.arch);
@@ -316,6 +443,7 @@ function uninstallNasiko(options = {}, dependencies = {}) {
let binaryStaged = false;
let metadataStaged = false;
const rename = dependencies.rename || fs.renameSync;
const remove = dependencies.remove || (target => fs.rmSync(target));
try {
const status = (dependencies.inspectInstalled || inspectInstalledNasiko)(destination);
if (!status.installed) return { ...plan, dryRun: false, removed: false };
@@ -325,11 +453,16 @@ function uninstallNasiko(options = {}, dependencies = {}) {
rename(metadataPath, metadataTombstone);
metadataStaged = true;
const cleanupPending = [];
try { fs.rmSync(metadataTombstone); } catch (_error) { cleanupPending.push(metadataTombstone); }
try { remove(metadataTombstone); } catch (_error) { cleanupPending.push(metadataTombstone); }
metadataStaged = false;
try { fs.rmSync(binaryTombstone); } catch (_error) { cleanupPending.push(binaryTombstone); }
try { remove(binaryTombstone); } catch (_error) { cleanupPending.push(binaryTombstone); }
binaryStaged = false;
return { ...plan, dryRun: false, removed: true, cleanupPending };
if (cleanupPending.length > 0) {
const cleanupError = new Error(`Nasiko uninstall is incomplete; retained staged file(s): ${cleanupPending.join(', ')}. Remove these files before reinstalling.`);
cleanupError.cleanupPending = cleanupPending;
throw cleanupError;
}
return { ...plan, dryRun: false, removed: true, cleanupPending: [] };
} catch (error) {
if (metadataStaged && !fs.existsSync(metadataPath)) rename(metadataTombstone, metadataPath);
if (binaryStaged && !fs.existsSync(destination)) rename(binaryTombstone, destination);
+31
View File
@@ -0,0 +1,31 @@
'use strict';
const os = require('os');
const path = require('path');
const { resolveInvocationEnvironment } = require('./invocation-environment');
function configuredDirectory(environment, name) {
const value = environment && environment[name];
return typeof value === 'string' && value.trim() !== ''
? path.resolve(value.trim())
: null;
}
function resolveOpencodeConfigRoot(options = {}) {
const environment = resolveInvocationEnvironment(options);
const explicitRoot = configuredDirectory(environment, 'OPENCODE_CONFIG_DIR');
if (explicitRoot) {
return explicitRoot;
}
const xdgConfigRoot = configuredDirectory(environment, 'XDG_CONFIG_HOME');
if (xdgConfigRoot) {
return path.join(xdgConfigRoot, 'opencode');
}
return path.join(path.resolve(options.homeDir || os.homedir()), '.config', 'opencode');
}
module.exports = {
resolveOpencodeConfigRoot,
};
@@ -317,6 +317,7 @@ function reconcileCurrentInstallState(store, options = {}) {
homeDir: options.homeDir,
projectRoot: options.projectRoot,
targets: options.targets,
env: options.env,
});
let result = reconcileInstallStateProjections(store, records);
try {
+1
View File
@@ -72,6 +72,7 @@ function main() {
const records = discoverInstalledStates({
homeDir: process.env.HOME || os.homedir(),
env: process.env,
projectRoot: process.cwd(),
targets: options.targets,
}).filter(record => record.exists);
+1 -1
View File
@@ -13,7 +13,7 @@ const {
function helpText() {
return `
ECC Nasiko control-plane bridge
ECC experimental Nasiko CLI lifecycle bridge
Usage:
ecc nasiko status [--install-dir <absolute-path>] [--json]
+2
View File
@@ -81,6 +81,7 @@ async function main() {
const result = repairInstalledStates({
repoRoot: require('path').join(__dirname, '..'),
homeDir: process.env.HOME || os.homedir(),
env: process.env,
projectRoot: process.cwd(),
targets: options.targets,
dryRun: options.dryRun,
@@ -89,6 +90,7 @@ async function main() {
const { reconcileCanonicalInstallStates } = require('./lib/install-state-store-sync');
result.installStateProjection = await reconcileCanonicalInstallStates({
homeDir: process.env.HOME || os.homedir(),
env: process.env,
projectRoot: process.cwd(),
targets: options.targets,
});
+1
View File
@@ -467,6 +467,7 @@ async function main() {
const installStateProjection = reconcileCurrentInstallState(store, {
homeDir: process.env.HOME || os.homedir(),
env: process.env,
projectRoot: process.cwd(),
});
const storedStatus = store.getStatus({
+73 -27
View File
@@ -1,17 +1,24 @@
#!/usr/bin/env node
const os = require('os');
const path = require('path');
const { uninstallInstalledStates } = require('./lib/install-lifecycle');
const { SUPPORTED_INSTALL_TARGETS } = require('./lib/install-manifests');
const { exitFeedbackLines } = require('./lib/feedback-links');
const { uninstallLegacyCodexSync } = require('./lib/codex-legacy-sync');
const {
legacyCodexSyncStateExists,
uninstallLegacyCodexSync,
} = require('./lib/codex-legacy-sync');
function showHelp(exitCode = 0) {
console.log(`
Usage: node scripts/uninstall.js [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--legacy-codex-sync] [--dry-run] [--json]
Remove ECC-managed files recorded in install-state for the current context.
Use --legacy-codex-sync explicitly for the older sync-ecc-to-codex.sh installation.
When no install-state is found, the uninstaller also detects and removes
legacy sync-ecc-to-codex.sh artifacts, but only when a legacy ownership
manifest is present. Use --legacy-codex-sync to force the legacy path
explicitly, including marker-only AGENTS.md cleanup.
`);
process.exit(exitCode);
}
@@ -87,6 +94,30 @@ function printHuman(result) {
}
}
function legacyCodexSyncStateDetected(codexHome) {
return legacyCodexSyncStateExists(codexHome);
}
function printLegacy(result, dryRun) {
console.log('Legacy Codex sync cleanup summary:\n');
console.log(`Status: ${result.status.toUpperCase()}`);
const paths = dryRun ? result.plannedRemovals : result.removedPaths;
console.log(`${dryRun ? 'Planned changes' : 'Removed paths'}: ${paths.length}`);
if (result.retainedPaths.length > 0) {
console.log(`Retained paths: ${result.retainedPaths.length}`);
for (const retainedPath of result.retainedPaths) console.log(` - ${retainedPath}`);
}
for (const warning of result.warnings) console.log(`Warning: ${warning}`);
}
function codexHomePath() {
return process.env.CODEX_HOME || path.join(process.env.HOME || os.homedir(), '.codex');
}
function includesCodexTarget(targets) {
return targets.length === 0 || targets.includes('codex');
}
async function main() {
try {
const options = parseArgs(process.argv);
@@ -97,41 +128,56 @@ async function main() {
if (options.legacyCodexSync && options.targets.length > 0) {
throw new Error('--legacy-codex-sync cannot be combined with --target');
}
const result = options.legacyCodexSync
? uninstallLegacyCodexSync({
codexHome: process.env.CODEX_HOME,
dryRun: options.dryRun,
})
: uninstallInstalledStates({
homeDir: process.env.HOME || os.homedir(),
projectRoot: process.cwd(),
targets: options.targets,
dryRun: options.dryRun,
});
if (!options.dryRun && !options.legacyCodexSync) {
const { reconcileCanonicalInstallStates } = require('./lib/install-state-store-sync');
result.installStateProjection = await reconcileCanonicalInstallStates({
let result;
let mode = 'install-state';
if (options.legacyCodexSync) {
result = uninstallLegacyCodexSync({
codexHome: codexHomePath(),
dryRun: options.dryRun,
});
mode = 'legacy-codex-sync';
} else {
result = uninstallInstalledStates({
homeDir: process.env.HOME || os.homedir(),
env: process.env,
projectRoot: process.cwd(),
targets: options.targets,
dryRun: options.dryRun,
});
if (
result.results.length === 0
&& includesCodexTarget(options.targets)
&& legacyCodexSyncStateDetected(codexHomePath())
) {
result = uninstallLegacyCodexSync({
codexHome: codexHomePath(),
dryRun: options.dryRun,
});
mode = 'legacy-codex-sync';
}
if (mode === 'install-state' && !options.dryRun) {
const { reconcileCanonicalInstallStates } = require('./lib/install-state-store-sync');
result.installStateProjection = await reconcileCanonicalInstallStates({
homeDir: process.env.HOME || os.homedir(),
env: process.env,
projectRoot: process.cwd(),
targets: options.targets,
});
}
}
const hasErrors = options.legacyCodexSync
const hasErrors = mode === 'legacy-codex-sync'
? result.status === 'partial'
: result.summary.errorCount > 0 || result.summary.partialCount > 0;
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else if (options.legacyCodexSync) {
console.log('Legacy Codex sync cleanup summary:\n');
console.log(`Status: ${result.status.toUpperCase()}`);
const paths = options.dryRun ? result.plannedRemovals : result.removedPaths;
console.log(`${options.dryRun ? 'Planned changes' : 'Removed paths'}: ${paths.length}`);
if (result.retainedPaths.length > 0) {
console.log(`Retained paths: ${result.retainedPaths.length}`);
for (const retainedPath of result.retainedPaths) console.log(` - ${retainedPath}`);
}
for (const warning of result.warnings) console.log(`Warning: ${warning}`);
} else if (mode === 'legacy-codex-sync') {
printLegacy(result, options.dryRun);
} else {
printHuman(result);
}