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
+87 -3
View File
@@ -8,6 +8,7 @@ const path = require('path');
const README = path.join(__dirname, '..', '..', 'README.md');
const RULES_README = path.join(__dirname, '..', '..', 'rules', 'README.md');
const CODEX_AGENTS = path.join(__dirname, '..', '..', '.codex', 'AGENTS.md');
function test(name, fn) {
try {
@@ -29,6 +30,7 @@ function runTests() {
const readme = fs.readFileSync(README, 'utf8');
const rulesReadme = fs.readFileSync(RULES_README, 'utf8');
const codexAgents = fs.readFileSync(CODEX_AGENTS, 'utf8');
if (test('README marks one default path and warns against stacked installs', () => {
assert.ok(
@@ -50,10 +52,26 @@ function runTests() {
})) passed++; else failed++;
if (test('README leads with the idempotent guided plugin setup path', () => {
const topClaudeSectionIndex = readme.indexOf('## Install with Claude Code');
const topGuidedCommandIndex = readme.indexOf('npx ecc-universal setup', topClaudeSectionIndex);
const nativePluginCommandIndex = readme.indexOf('/plugin marketplace add', topClaudeSectionIndex);
const installSectionIndex = readme.indexOf('## Install ECC');
const guidedCommandIndex = readme.indexOf('npx ecc-universal setup', installSectionIndex);
const claudeDetailsIndex = readme.indexOf('### Claude Code details', installSectionIndex);
assert.ok(
readme.includes('npx ecc-universal setup'),
topGuidedCommandIndex > topClaudeSectionIndex
&& topGuidedCommandIndex < nativePluginCommandIndex,
'README should lead its public install surface with the canonical package command'
);
assert.ok(
guidedCommandIndex > installSectionIndex,
'README should lead new users to the package-name setup command'
);
assert.ok(
guidedCommandIndex < claudeDetailsIndex,
'README should show the recommended universal command before provider-specific details'
);
assert.ok(
readme.includes('installs, updates, or safely moves `ecc@ecc`'),
'README should explain that rerunning guided setup reconciles existing installs'
@@ -103,6 +121,17 @@ function runTests() {
readme.includes('node scripts/ecc.js doctor'),
'README should document doctor before reinstalling'
);
for (const command of [
'npx ecc-universal list-installed',
'npx ecc-universal doctor',
'npx ecc-universal repair',
'npx ecc-universal uninstall --dry-run',
]) {
assert.ok(
readme.includes(command),
`README should document the package-runner lifecycle command: ${command}`
);
}
assert.ok(
readme.includes('ECC only removes files recorded in its install-state.'),
'README should explain uninstall safety boundaries'
@@ -119,8 +148,12 @@ function runTests() {
'README should document the shell minimal profile command'
);
assert.ok(
readme.includes('npx ecc-install --profile minimal --target claude'),
'README should document the npx minimal profile command'
readme.includes('npx ecc-universal install --profile minimal --target claude'),
'README should document the published universal-package minimal profile command'
);
assert.ok(
!/^\s*npx ecc-install\b/m.test(readme),
'README code examples must not invoke the unpublished ecc-install package'
);
assert.ok(
readme.includes('--profile core --without baseline:hooks --target claude'),
@@ -175,6 +208,57 @@ function runTests() {
}
})) passed++; else failed++;
if (test('README describes the post-release universal install contract', () => {
assert.ok(
readme.includes('Node.js 18 or newer'),
'README should state the runtime required by ecc-universal'
);
assert.ok(
!readme.includes('During registry propagation'),
'README should not retain the temporary 2.1 registry fallback after 2.2 is live'
);
assert.ok(
!/codex[^\n]*(?:marketplace|plugin)[^\n]*(?:experimental|unreliable)/i.test(readme),
'README should not contradict the supported native Codex install guidance'
);
assert.ok(
readme.includes('| Skills | Native installed set | Native plugin set |'),
'README capability map should describe the native Codex skill set'
);
assert.ok(
readme.includes('| ECC hooks | Native plugin hooks | Native reviewed subset with explicit trust |'),
'README capability map should describe the native Codex hook subset'
);
assert.ok(
readme.includes("Codex's narrower native hook set is supplemented"),
'README architecture notes should preserve the native Codex hook subset boundary'
);
assert.ok(
!readme.includes("Codex's lack of hooks"),
'README should not deny the shipped native Codex hook subset'
);
assert.ok(
readme.includes("# Recommended current install: add ECC's native plugin from the repo marketplace"),
'README Codex detail should lead with the native plugin install'
);
assert.ok(
readme.includes('Legacy copied-configuration compatibility is still available'),
'README Codex detail should label the sync path as compatibility-only'
);
assert.ok(
!readme.includes('# Automatic setup: sync ECC assets'),
'README should not present the legacy Codex sync as the primary setup'
);
assert.ok(
codexAgents.includes('Reviewed native subset with explicit trust in `/hooks`'),
'Packaged Codex guidance should describe the shipped trusted hook subset'
);
assert.ok(
!/not yet supported|codex lacks hooks|security without hooks/i.test(codexAgents),
'Packaged Codex guidance should not deny native hook support'
);
})) passed++; else failed++;
if (test('README documents Cursor agent namespace and loading caveat', () => {
assert.ok(
readme.includes('`.cursor/agents/ecc-*.md`'),
+103 -10
View File
@@ -56,18 +56,21 @@ function createFixture(state = {}) {
callsPath,
};
}
function runSetup(fixture, args) {
function runSetup(fixture, args, options = {}) {
const env = {
...process.env,
HOME: fixture.homeDir,
USERPROFILE: fixture.homeDir,
CLAUDE_CONFIG_DIR: fixture.configDir,
PATH: options.path || `${fixture.binDir}${path.delimiter}${process.env.PATH || ''}`,
ECC_TEST_CLAUDE_STATE: fixture.statePath,
ECC_TEST_CLAUDE_CALLS: fixture.callsPath,
...options.env,
};
if (options.defaultClaudeConfig) delete env.CLAUDE_CONFIG_DIR;
return spawnSync(process.execPath, [setupScript, ...args], {
cwd: fixture.projectRoot,
env: {
...process.env,
HOME: fixture.homeDir,
USERPROFILE: fixture.homeDir,
CLAUDE_CONFIG_DIR: fixture.configDir,
PATH: `${fixture.binDir}${path.delimiter}${process.env.PATH || ''}`,
ECC_TEST_CLAUDE_STATE: fixture.statePath,
ECC_TEST_CLAUDE_CALLS: fixture.callsPath,
},
env,
encoding: 'utf8',
timeout: 15000,
});
@@ -267,6 +270,96 @@ test('dry-run JSON emits JSON only and reads inventory without mutation', () =>
});
});
test('dry-run leaves a pristine HOME unchanged when Claude inventory creates backups', () => {
withFixture({}, fixture => {
assert.deepStrictEqual(fs.readdirSync(fixture.homeDir), []);
assert.deepStrictEqual(fs.readdirSync(fixture.projectRoot), []);
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', 'local',
'--hooks', 'minimal',
'--dry-run',
'--json',
], {
defaultClaudeConfig: true,
env: { ECC_TEST_CLAUDE_CREATE_READ_ARTIFACTS: '1' },
});
assert.strictEqual(result.status, 0, result.stderr);
assert.deepStrictEqual(fs.readdirSync(fixture.homeDir), []);
assert.deepStrictEqual(fs.readdirSync(fixture.projectRoot), []);
});
});
test('dry-run isolates pre-existing Claude backups and symlinked project settings', () => {
if (process.platform === 'win32') return;
withFixture({}, fixture => {
const defaultConfigDir = path.join(fixture.homeDir, '.claude');
const backupDir = path.join(defaultConfigDir, 'backups');
const backupPath = path.join(backupDir, 'existing.backup');
const statePath = path.join(fixture.homeDir, '.claude.json');
const projectConfigDir = path.join(fixture.projectRoot, '.claude');
const settingsTarget = path.join(fixture.root, 'settings-target.json');
const settingsPath = path.join(projectConfigDir, 'settings.local.json');
const xdgDataHome = path.join(fixture.homeDir, 'xdg-data');
fs.mkdirSync(backupDir, { recursive: true });
fs.mkdirSync(projectConfigDir, { recursive: true });
fs.writeFileSync(backupPath, 'original-backup\n');
fs.writeFileSync(statePath, '{"original":true}\n');
fs.writeFileSync(settingsTarget, '{"enabledPlugins":{"ecc@ecc":true}}\n');
fs.symlinkSync(settingsTarget, settingsPath, 'file');
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', 'local',
'--hooks', 'minimal',
'--dry-run',
'--json',
], {
defaultClaudeConfig: true,
env: {
ECC_TEST_CLAUDE_CREATE_READ_ARTIFACTS: '1',
ECC_TEST_CLAUDE_OVERWRITE_READ_ARTIFACTS: '1',
ECC_TEST_CLAUDE_WRITE_XDG_DATA: '1',
XDG_DATA_HOME: xdgDataHome,
},
});
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(fs.readFileSync(backupPath, 'utf8'), 'original-backup\n');
assert.strictEqual(fs.readFileSync(statePath, 'utf8'), '{"original":true}\n');
assert.strictEqual(
fs.readFileSync(settingsTarget, 'utf8'),
'{"enabledPlugins":{"ecc@ecc":true}}\n'
);
assert.strictEqual(fs.lstatSync(settingsPath).isSymbolicLink(), true);
assert.strictEqual(
fs.existsSync(path.join(xdgDataHome, 'claude-provider-read.json')),
false
);
});
});
test('missing Git fails with an actionable prerequisite during dry-run', () => {
withFixture({}, fixture => {
const result = runSetup(fixture, [
'--mode', 'claude-plugin',
'--scope', 'user',
'--hooks', 'standard',
'--dry-run',
'--json',
], { path: fixture.binDir });
assert.strictEqual(result.status, 1);
assert.strictEqual(result.stdout, '');
const payload = JSON.parse(result.stderr);
assert.strictEqual(payload.error.code, 'GIT_NOT_FOUND');
assert.strictEqual(payload.error.phase, 'preflight');
assert.match(payload.error.message, /Git is required for Claude marketplace setup/i);
assert.match(payload.error.message, /install Git/i);
assert.doesNotMatch(payload.error.message, /ERR_STREAM_PREMATURE_CLOSE/i);
assert.deepStrictEqual(readCalls(fixture), []);
});
});
test('setup automatically migrates an existing install to the selected scope and hooks', () => {
withFixture({
plugins: [{ id: 'ecc@ecc', scope: 'local', enabled: true, version: '1.9.0' }],