feat: strengthen install lifecycle and target adapters (#512)

* fix: strengthen install lifecycle adapters

* fix: restore template content on uninstall
This commit is contained in:
Affaan Mustafa
2026-03-15 21:47:31 -07:00
committed by GitHub
parent 1e0238de96
commit 131f977841
11 changed files with 1987 additions and 125 deletions

View File

@@ -10,6 +10,8 @@ const path = require('path');
const {
buildDoctorReport,
discoverInstalledStates,
repairInstalledStates,
uninstallInstalledStates,
} = require('../../scripts/lib/install-lifecycle');
const {
createInstallState,
@@ -350,6 +352,385 @@ function runTests() {
}
})) passed++; else failed++;
if (test('repair restores render-template outputs from recorded rendered content', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(homeDir, '.claude');
const statePath = path.join(targetRoot, 'ecc', 'install-state.json');
const destinationPath = path.join(targetRoot, 'plugin.json');
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
fs.writeFileSync(destinationPath, '{"drifted":true}\n');
writeState(statePath, {
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
targetRoot,
installStatePath: statePath,
request: {
profile: null,
modules: [],
legacyLanguages: ['typescript'],
legacyMode: true,
},
resolution: {
selectedModules: ['legacy-claude-rules'],
skippedModules: [],
},
operations: [
{
kind: 'render-template',
moduleId: 'platform-configs',
sourceRelativePath: '.claude-plugin/plugin.json.template',
destinationPath,
strategy: 'render-template',
ownership: 'managed',
scaffoldOnly: false,
renderedContent: '{"ok":true}\n',
},
],
source: {
repoVersion: CURRENT_PACKAGE_VERSION,
repoCommit: 'abc123',
manifestVersion: CURRENT_MANIFEST_VERSION,
},
});
const result = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['claude'],
});
assert.strictEqual(result.results[0].status, 'repaired');
assert.strictEqual(fs.readFileSync(destinationPath, 'utf8'), '{"ok":true}\n');
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('repair reapplies merge-json operations without clobbering unrelated keys', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(projectRoot, '.cursor');
const statePath = path.join(targetRoot, 'ecc-install-state.json');
const destinationPath = path.join(targetRoot, 'hooks.json');
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
fs.writeFileSync(destinationPath, JSON.stringify({
existing: true,
nested: {
enabled: false,
},
}, null, 2));
writeState(statePath, {
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
targetRoot,
installStatePath: statePath,
request: {
profile: null,
modules: [],
legacyLanguages: ['typescript'],
legacyMode: true,
},
resolution: {
selectedModules: ['legacy-cursor-install'],
skippedModules: [],
},
operations: [
{
kind: 'merge-json',
moduleId: 'platform-configs',
sourceRelativePath: '.cursor/hooks.json',
destinationPath,
strategy: 'merge-json',
ownership: 'managed',
scaffoldOnly: false,
mergePayload: {
nested: {
enabled: true,
},
managed: 'yes',
},
},
],
source: {
repoVersion: CURRENT_PACKAGE_VERSION,
repoCommit: 'abc123',
manifestVersion: CURRENT_MANIFEST_VERSION,
},
});
const result = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['cursor'],
});
assert.strictEqual(result.results[0].status, 'repaired');
assert.deepStrictEqual(JSON.parse(fs.readFileSync(destinationPath, 'utf8')), {
existing: true,
nested: {
enabled: true,
},
managed: 'yes',
});
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('repair re-applies managed remove operations when files reappear', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(projectRoot, '.cursor');
const statePath = path.join(targetRoot, 'ecc-install-state.json');
const destinationPath = path.join(targetRoot, 'legacy-note.txt');
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
fs.writeFileSync(destinationPath, 'stale');
writeState(statePath, {
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
targetRoot,
installStatePath: statePath,
request: {
profile: null,
modules: [],
legacyLanguages: ['typescript'],
legacyMode: true,
},
resolution: {
selectedModules: ['legacy-cursor-install'],
skippedModules: [],
},
operations: [
{
kind: 'remove',
moduleId: 'platform-configs',
sourceRelativePath: '.cursor/legacy-note.txt',
destinationPath,
strategy: 'remove',
ownership: 'managed',
scaffoldOnly: false,
},
],
source: {
repoVersion: CURRENT_PACKAGE_VERSION,
repoCommit: 'abc123',
manifestVersion: CURRENT_MANIFEST_VERSION,
},
});
const result = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['cursor'],
});
assert.strictEqual(result.results[0].status, 'repaired');
assert.ok(!fs.existsSync(destinationPath));
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('uninstall restores JSON merged files from recorded previous content', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(projectRoot, '.cursor');
const statePath = path.join(targetRoot, 'ecc-install-state.json');
const destinationPath = path.join(targetRoot, 'hooks.json');
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
fs.writeFileSync(destinationPath, JSON.stringify({
existing: true,
managed: true,
}, null, 2));
writeState(statePath, {
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
targetRoot,
installStatePath: statePath,
request: {
profile: null,
modules: [],
legacyLanguages: ['typescript'],
legacyMode: true,
},
resolution: {
selectedModules: ['legacy-cursor-install'],
skippedModules: [],
},
operations: [
{
kind: 'merge-json',
moduleId: 'platform-configs',
sourceRelativePath: '.cursor/hooks.json',
destinationPath,
strategy: 'merge-json',
ownership: 'managed',
scaffoldOnly: false,
mergePayload: {
managed: true,
},
previousContent: JSON.stringify({
existing: true,
}, null, 2),
},
],
source: {
repoVersion: CURRENT_PACKAGE_VERSION,
repoCommit: 'abc123',
manifestVersion: CURRENT_MANIFEST_VERSION,
},
});
const result = uninstallInstalledStates({
homeDir,
projectRoot,
targets: ['cursor'],
});
assert.strictEqual(result.results[0].status, 'uninstalled');
assert.deepStrictEqual(JSON.parse(fs.readFileSync(destinationPath, 'utf8')), {
existing: true,
});
assert.ok(!fs.existsSync(statePath));
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('uninstall restores rendered template files from recorded previous content', () => {
const tempDir = createTempDir('install-lifecycle-');
try {
const targetRoot = path.join(tempDir, '.claude');
const statePath = path.join(targetRoot, 'ecc', 'install-state.json');
const destinationPath = path.join(targetRoot, 'plugin.json');
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
fs.writeFileSync(destinationPath, '{"generated":true}\n');
writeInstallState(statePath, createInstallState({
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
targetRoot,
installStatePath: statePath,
request: {
profile: 'core',
modules: ['platform-configs'],
includeComponents: [],
excludeComponents: [],
legacyLanguages: [],
legacyMode: false,
},
resolution: {
selectedModules: ['platform-configs'],
skippedModules: [],
},
source: {
repoVersion: '1.8.0',
repoCommit: 'abc123',
manifestVersion: 1,
},
operations: [
{
kind: 'render-template',
moduleId: 'platform-configs',
sourceRelativePath: '.claude/plugin.json.template',
destinationPath,
strategy: 'render-template',
ownership: 'managed',
scaffoldOnly: false,
renderedContent: '{"generated":true}\n',
previousContent: '{"existing":true}\n',
},
],
}));
const result = uninstallInstalledStates({
homeDir: tempDir,
projectRoot: tempDir,
targets: ['claude'],
});
assert.strictEqual(result.summary.uninstalledCount, 1);
assert.strictEqual(fs.readFileSync(destinationPath, 'utf8'), '{"existing":true}\n');
assert.ok(!fs.existsSync(statePath));
} finally {
cleanup(tempDir);
}
})) passed++; else failed++;
if (test('uninstall restores files removed during install when previous content is recorded', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(projectRoot, '.cursor');
const statePath = path.join(targetRoot, 'ecc-install-state.json');
const destinationPath = path.join(targetRoot, 'legacy-note.txt');
fs.mkdirSync(targetRoot, { recursive: true });
writeState(statePath, {
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
targetRoot,
installStatePath: statePath,
request: {
profile: null,
modules: [],
legacyLanguages: ['typescript'],
legacyMode: true,
},
resolution: {
selectedModules: ['legacy-cursor-install'],
skippedModules: [],
},
operations: [
{
kind: 'remove',
moduleId: 'platform-configs',
sourceRelativePath: '.cursor/legacy-note.txt',
destinationPath,
strategy: 'remove',
ownership: 'managed',
scaffoldOnly: false,
previousContent: 'restore me\n',
},
],
source: {
repoVersion: CURRENT_PACKAGE_VERSION,
repoCommit: 'abc123',
manifestVersion: CURRENT_MANIFEST_VERSION,
},
});
const result = uninstallInstalledStates({
homeDir,
projectRoot,
targets: ['cursor'],
});
assert.strictEqual(result.results[0].status, 'uninstalled');
assert.strictEqual(fs.readFileSync(destinationPath, 'utf8'), 'restore me\n');
assert.ok(!fs.existsSync(statePath));
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}

View File

@@ -117,6 +117,56 @@ function runTests() {
}
})) passed++; else failed++;
if (test('deep-clones nested operation metadata for lifecycle-managed operations', () => {
const operation = {
kind: 'merge-json',
moduleId: 'platform-configs',
sourceRelativePath: '.cursor/hooks.json',
destinationPath: '/repo/.cursor/hooks.json',
strategy: 'merge-json',
ownership: 'managed',
scaffoldOnly: false,
mergePayload: {
nested: {
enabled: true,
},
},
previousValue: {
nested: {
enabled: false,
},
},
};
const state = createInstallState({
adapter: { id: 'cursor-project' },
targetRoot: '/repo/.cursor',
installStatePath: '/repo/.cursor/ecc-install-state.json',
request: {
profile: null,
modules: ['platform-configs'],
legacyLanguages: [],
legacyMode: false,
},
resolution: {
selectedModules: ['platform-configs'],
skippedModules: [],
},
operations: [operation],
source: {
repoVersion: '1.9.0',
repoCommit: 'abc123',
manifestVersion: 1,
},
});
operation.mergePayload.nested.enabled = false;
operation.previousValue.nested.enabled = true;
assert.strictEqual(state.operations[0].mergePayload.nested.enabled, true);
assert.strictEqual(state.operations[0].previousValue.nested.enabled, false);
})) passed++; else failed++;
if (test('rejects invalid install-state payloads on read', () => {
const testDir = createTestDir();
const statePath = path.join(testDir, 'ecc-install-state.json');
@@ -132,6 +182,48 @@ function runTests() {
}
})) passed++; else failed++;
if (test('rejects unexpected properties and missing required request fields', () => {
const testDir = createTestDir();
const statePath = path.join(testDir, 'ecc-install-state.json');
try {
fs.writeFileSync(statePath, JSON.stringify({
schemaVersion: 'ecc.install.v1',
installedAt: '2026-03-13T00:00:00Z',
unexpected: true,
target: {
id: 'cursor-project',
root: '/repo/.cursor',
installStatePath: '/repo/.cursor/ecc-install-state.json',
},
request: {
modules: [],
includeComponents: [],
excludeComponents: [],
legacyLanguages: [],
legacyMode: false,
},
resolution: {
selectedModules: [],
skippedModules: [],
},
source: {
repoVersion: '1.9.0',
repoCommit: 'abc123',
manifestVersion: 1,
},
operations: [],
}, null, 2));
assert.throws(
() => readInstallState(statePath),
/Invalid install-state/
);
} finally {
cleanupTestDir(testDir);
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}

View File

@@ -60,7 +60,7 @@ function runTests() {
})) passed++; else failed++;
if (test('plans scaffold operations and flattens native target roots', () => {
const repoRoot = '/repo/ecc';
const repoRoot = path.join(__dirname, '..', '..');
const projectRoot = '/workspace/app';
const modules = [
{
@@ -85,15 +85,124 @@ function runTests() {
assert.strictEqual(plan.installStatePath, path.join(projectRoot, '.cursor', 'ecc-install-state.json'));
const flattened = plan.operations.find(operation => operation.sourceRelativePath === '.cursor');
const preserved = plan.operations.find(operation => operation.sourceRelativePath === 'rules');
const preserved = plan.operations.find(operation => (
operation.sourceRelativePath === path.join('rules', 'common', 'coding-style.md')
));
assert.ok(flattened, 'Should include .cursor scaffold operation');
assert.strictEqual(flattened.strategy, 'sync-root-children');
assert.strictEqual(flattened.destinationPath, path.join(projectRoot, '.cursor'));
assert.ok(preserved, 'Should include rules scaffold operation');
assert.strictEqual(preserved.strategy, 'preserve-relative-path');
assert.strictEqual(preserved.destinationPath, path.join(projectRoot, '.cursor', 'rules'));
assert.ok(preserved, 'Should include flattened rules scaffold operations');
assert.strictEqual(preserved.strategy, 'flatten-copy');
assert.strictEqual(
preserved.destinationPath,
path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.md')
);
})) passed++; else failed++;
if (test('plans cursor rules with flat namespaced filenames to avoid rule collisions', () => {
const repoRoot = path.join(__dirname, '..', '..');
const projectRoot = '/workspace/app';
const plan = planInstallTargetScaffold({
target: 'cursor',
repoRoot,
projectRoot,
modules: [
{
id: 'rules-core',
paths: ['rules'],
},
],
});
assert.ok(
plan.operations.some(operation => (
operation.sourceRelativePath === path.join('rules', 'common', 'coding-style.md')
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.md')
)),
'Should flatten common rules into namespaced files'
);
assert.ok(
plan.operations.some(operation => (
operation.sourceRelativePath === path.join('rules', 'typescript', 'testing.md')
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'typescript-testing.md')
)),
'Should flatten language rules into namespaced files'
);
assert.ok(
!plan.operations.some(operation => (
operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common', 'coding-style.md')
)),
'Should not preserve nested rule directories for cursor installs'
);
})) passed++; else failed++;
if (test('plans antigravity remaps for workflows, skills, and flat rules', () => {
const repoRoot = path.join(__dirname, '..', '..');
const projectRoot = '/workspace/app';
const plan = planInstallTargetScaffold({
target: 'antigravity',
repoRoot,
projectRoot,
modules: [
{
id: 'commands-core',
paths: ['commands'],
},
{
id: 'agents-core',
paths: ['agents'],
},
{
id: 'rules-core',
paths: ['rules'],
},
],
});
assert.ok(
plan.operations.some(operation => (
operation.sourceRelativePath === 'commands'
&& operation.destinationPath === path.join(projectRoot, '.agent', 'workflows')
)),
'Should remap commands into workflows'
);
assert.ok(
plan.operations.some(operation => (
operation.sourceRelativePath === 'agents'
&& operation.destinationPath === path.join(projectRoot, '.agent', 'skills')
)),
'Should remap agents into skills'
);
assert.ok(
plan.operations.some(operation => (
operation.sourceRelativePath === path.join('rules', 'common', 'coding-style.md')
&& operation.destinationPath === path.join(projectRoot, '.agent', 'rules', 'common-coding-style.md')
)),
'Should flatten common rules for antigravity'
);
})) passed++; else failed++;
if (test('exposes validate and planOperations on adapters', () => {
const claudeAdapter = getInstallTargetAdapter('claude');
const cursorAdapter = getInstallTargetAdapter('cursor');
assert.strictEqual(typeof claudeAdapter.planOperations, 'function');
assert.strictEqual(typeof claudeAdapter.validate, 'function');
assert.deepStrictEqual(
claudeAdapter.validate({ homeDir: '/Users/example', repoRoot: '/repo/ecc' }),
[]
);
assert.strictEqual(typeof cursorAdapter.planOperations, 'function');
assert.strictEqual(typeof cursorAdapter.validate, 'function');
assert.deepStrictEqual(
cursorAdapter.validate({ projectRoot: '/workspace/app', repoRoot: '/repo/ecc' }),
[]
);
})) passed++; else failed++;
if (test('throws on unknown target adapter', () => {