mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-06 17:57:52 +02:00
Merge remote-tracking branch 'origin/main' into maint/pr-2866-current
This commit is contained in:
@@ -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 = [];
|
||||
|
||||
@@ -15,6 +15,10 @@ const ignoredDirs = new Set([
|
||||
'.dmux',
|
||||
'.next',
|
||||
'.venv',
|
||||
'.pytest_cache',
|
||||
'.ruff_cache',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
'venv',
|
||||
]);
|
||||
|
||||
@@ -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:',
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, '/')}`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 : [],
|
||||
|
||||
@@ -51,6 +51,7 @@ async function reconcileCanonicalInstallStates(options = {}) {
|
||||
homeDir: options.homeDir,
|
||||
projectRoot: options.projectRoot,
|
||||
targets: options.targets,
|
||||
env: options.env,
|
||||
discoverInstalledStates: options.discoverInstalledStates,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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')
|
||||
];
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user