fix(install): harden universal setup release path (#2888)

* fix(install): harden universal setup release path

* docs(adal): use ecc-universal doctor command
This commit is contained in:
haelyra
2026-08-30 18:54:00 -04:00
committed by GitHub
parent ce64e417fd
commit 005eff40fd
28 changed files with 1252 additions and 98 deletions
+182
View File
@@ -0,0 +1,182 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { realpathNearestExisting } = require('./path-safety');
function readSnapshotFile(filePath) {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) {
const error = new Error(`Claude dry-run state is not a regular file: ${filePath}`);
error.code = 'INVALID_DRY_RUN_STATE';
throw error;
}
return fs.readFileSync(filePath);
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
}
function claudeStateFilePath(paths, options) {
const hasCustomConfigDir = (
options.configDir !== undefined
|| Boolean(process.env.CLAUDE_CONFIG_DIR)
);
return hasCustomConfigDir
? path.join(paths.configDir, '.claude.json')
: path.join(paths.homeDir, '.claude.json');
}
function remapSnapshotPath(value, mappings) {
if (typeof value !== 'string' || !path.isAbsolute(value)) return value;
for (const mapping of mappings) {
const relative = path.relative(mapping.source, value);
if (
relative === ''
|| (
relative !== '..'
&& !relative.startsWith(`..${path.sep}`)
&& !path.isAbsolute(relative)
)
) {
return relative === '' ? mapping.destination : path.join(mapping.destination, relative);
}
}
return value;
}
function remapSnapshotValue(value, mappings) {
if (typeof value === 'string') return remapSnapshotPath(value, mappings);
if (Array.isArray(value)) {
return value.map(entry => remapSnapshotValue(entry, mappings));
}
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
remapSnapshotPath(key, mappings),
remapSnapshotValue(entry, mappings),
]));
}
function copyJsonSnapshot(sourcePath, destinationPath, mappings) {
const content = readSnapshotFile(sourcePath);
if (content === null) return;
let snapshot = content;
try {
const parsed = JSON.parse(content.toString('utf8'));
snapshot = Buffer.from(`${JSON.stringify(remapSnapshotValue(parsed, mappings), null, 2)}\n`);
} catch {
// Preserve malformed input so Claude reports the same inventory error from isolation.
}
fs.mkdirSync(path.dirname(destinationPath), { recursive: true, mode: 0o700 });
fs.writeFileSync(destinationPath, snapshot, { mode: 0o600 });
}
function createSnapshotMappings(entries) {
const mappings = [];
for (const entry of entries) {
const sources = new Set([
path.resolve(entry.source),
realpathNearestExisting(entry.source),
]);
for (const source of sources) {
mappings.push({ source, destination: entry.destination });
}
}
return mappings.sort((left, right) => right.source.length - left.source.length);
}
function createDryRunSandbox(paths, options, baseEnv) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-claude-dry-run-'));
const homeDir = path.join(root, 'home');
const configDir = path.join(root, 'config');
const projectRoot = path.join(root, 'project');
const tempDir = path.join(root, 'tmp');
try {
fs.chmodSync(root, 0o700);
for (const directoryPath of [homeDir, configDir, projectRoot, tempDir]) {
fs.mkdirSync(directoryPath, { recursive: true, mode: 0o700 });
}
const mappings = createSnapshotMappings([
{ source: paths.projectRoot, destination: projectRoot },
{ source: paths.configDir, destination: configDir },
{ source: paths.homeDir, destination: homeDir },
]);
const snapshots = [
[claudeStateFilePath(paths, options), path.join(configDir, '.claude.json')],
[path.join(paths.configDir, 'settings.json'), path.join(configDir, 'settings.json')],
[path.join(paths.configDir, 'settings.local.json'), path.join(configDir, 'settings.local.json')],
[
path.join(paths.configDir, 'plugins', 'installed_plugins.json'),
path.join(configDir, 'plugins', 'installed_plugins.json'),
],
[
path.join(paths.configDir, 'plugins', 'known_marketplaces.json'),
path.join(configDir, 'plugins', 'known_marketplaces.json'),
],
[
path.join(paths.projectRoot, '.claude', 'settings.json'),
path.join(projectRoot, '.claude', 'settings.json'),
],
[
path.join(paths.projectRoot, '.claude', 'settings.local.json'),
path.join(projectRoot, '.claude', 'settings.local.json'),
],
];
for (const [sourcePath, destinationPath] of snapshots) {
copyJsonSnapshot(sourcePath, destinationPath, mappings);
}
return {
cwd: projectRoot,
env: {
...baseEnv,
APPDATA: path.join(root, 'appdata'),
CLAUDE_CONFIG_DIR: configDir,
CLAUDE_PROJECT_DIR: projectRoot,
HOME: homeDir,
INIT_CWD: projectRoot,
LOCALAPPDATA: path.join(root, 'localappdata'),
OLDPWD: projectRoot,
PWD: projectRoot,
TEMP: tempDir,
TMP: tempDir,
TMPDIR: tempDir,
USERPROFILE: homeDir,
XDG_CACHE_HOME: path.join(root, 'xdg-cache'),
XDG_CONFIG_HOME: path.join(root, 'xdg-config'),
XDG_DATA_HOME: path.join(root, 'xdg-data'),
XDG_STATE_HOME: path.join(root, 'xdg-state'),
},
root,
};
} catch (error) {
fs.rmSync(root, { force: true, recursive: true });
throw error;
}
}
function createDryRunClaudeRunner(run, paths, options = {}) {
return (args, runOptions = {}) => {
const sandbox = createDryRunSandbox(
paths,
options,
runOptions.env || process.env
);
try {
return run(args, {
...runOptions,
cwd: sandbox.cwd,
env: sandbox.env,
});
} finally {
fs.rmSync(sandbox.root, { force: true, recursive: true });
}
};
}
module.exports = {
createDryRunClaudeRunner,
};
+47 -1
View File
@@ -9,6 +9,7 @@ const {
hasExplicitCommitAttributionPreference,
withCommitAttributionDisabled,
} = require('./claude-commit-attribution');
const { createDryRunClaudeRunner } = require('./claude-dry-run-sandbox');
const { normalizeGitHubGitOrigin } = require('./github-origin');
const {
CURRENT_PLUGIN_ID,
@@ -173,6 +174,41 @@ function resolveWindowsCmdShim(command, env) {
.find(Boolean) || null;
}
function assertGitAvailable(options = {}, dependencies = {}) {
const spawn = dependencies.spawnSync || spawnSync;
const result = spawn('git', ['--version'], {
cwd: options.cwd || process.cwd(),
env: options.env || process.env,
encoding: 'utf8',
timeout: 10 * 1000,
windowsHide: true,
});
if (result.error?.code === 'ENOENT') {
fail(
'GIT_NOT_FOUND',
'Git is required for Claude marketplace setup but `git` is not on PATH. Install Git, ensure `git` is on PATH, then rerun ECC setup.',
{
phase: 'preflight',
recovery: [
'Install Git from https://git-scm.com/downloads and ensure `git` is on PATH.',
'Rerun ECC setup.',
],
}
);
}
if (result.error || result.status !== 0) {
const detail = String(result.stderr || result.stdout || result.error?.message || '').trim();
fail(
'GIT_UNAVAILABLE',
`Git is required for Claude marketplace setup but could not run${detail ? `: ${detail}` : '.'}`,
{
phase: 'preflight',
recovery: ['Repair Git, ensure `git --version` succeeds, then rerun ECC setup.'],
}
);
}
}
function runClaude(args, options = {}, dependencies = {}) {
const command = options.command || 'claude';
const spawn = dependencies.spawnSync || spawnSync;
@@ -566,8 +602,15 @@ function setupClaudePlugin(options = {}, dependencies = {}) {
const settingsPath = path.join(paths.configDir, 'settings.json');
const initialSettings = readSettings(settingsPath);
assertSafeLocalInventory(paths);
assertGitAvailable(
{ cwd: paths.projectRoot },
{ spawnSync: dependencies.spawnSync }
);
const run = dependencies.runClaude || runClaude;
const providerRun = dependencies.runClaude || runClaude;
const run = options.dryRun
? createDryRunClaudeRunner(providerRun, paths, options)
: providerRun;
const plugins = parsePluginList(
run(
['plugin', 'list', '--json'],
@@ -610,6 +653,7 @@ function setupClaudePlugin(options = {}, dependencies = {}) {
projectRoot: paths.projectRoot,
run,
scope: inventory.scope,
spawnSync: dependencies.spawnSync,
});
const action = ensurePluginAtScope({
hooks,
@@ -656,6 +700,8 @@ module.exports = {
buildWindowsCommandLine,
assertNoConflictingEccPlugins,
assertSafeLocalInventory,
assertGitAvailable,
createDryRunClaudeRunner,
currentEccPlugins,
deriveHookMode,
ensureOfficialMarketplace,
+11 -1
View File
@@ -10,7 +10,9 @@ const {
VALID_SCOPES,
assertNoConflictingEccPlugins,
assertSafeLocalInventory,
assertGitAvailable,
currentEccPlugins,
createDryRunClaudeRunner,
deriveHookMode,
ensureOfficialMarketplace,
ensurePluginAtScope,
@@ -256,7 +258,14 @@ function migrateClaudePluginScope(options = {}, dependencies = {}) {
const settingsPath = path.join(paths.configDir, 'settings.json');
const settings = readSettings(settingsPath);
assertSafeLocalInventory(paths);
const run = dependencies.runClaude || runClaude;
assertGitAvailable(
{ cwd: paths.projectRoot },
{ spawnSync: dependencies.spawnSync }
);
const providerRun = dependencies.runClaude || runClaude;
const run = options.dryRun
? createDryRunClaudeRunner(providerRun, paths, options)
: providerRun;
const plugins = readPluginInventory(run, paths.projectRoot, 'inventory');
const migration = assertMigrationInventory(plugins, options.scope);
const hooks = options.hooks === undefined
@@ -354,6 +363,7 @@ function migrateClaudePluginScope(options = {}, dependencies = {}) {
projectRoot: paths.projectRoot,
run,
scope: options.scope,
spawnSync: dependencies.spawnSync,
});
ensurePluginAtScope({
hookConfiguration,