mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-23 10:05:12 +02:00
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:
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user