diff --git a/scripts/lib/install-targets/cursor-project.js b/scripts/lib/install-targets/cursor-project.js index 527ba2a6..03d19b1d 100644 --- a/scripts/lib/install-targets/cursor-project.js +++ b/scripts/lib/install-targets/cursor-project.js @@ -1,11 +1,23 @@ +const fs = require('fs'); const path = require('path'); const { createFlatRuleOperations, createInstallTargetAdapter, + createManagedOperation, isForeignPlatformPath, } = require('./helpers'); +function toCursorRuleFileName(fileName, sourceRelativeFile) { + if (path.basename(sourceRelativeFile).toLowerCase() === 'readme.md') { + return null; + } + + return fileName.endsWith('.md') + ? `${fileName.slice(0, -3)}.mdc` + : fileName; +} + module.exports = createInstallTargetAdapter({ id: 'cursor-project', target: 'cursor', @@ -17,6 +29,7 @@ module.exports = createInstallTargetAdapter({ const modules = Array.isArray(input.modules) ? input.modules : (input.module ? [input.module] : []); + const seenDestinationPaths = new Set(); const { repoRoot, projectRoot, @@ -28,23 +41,98 @@ module.exports = createInstallTargetAdapter({ homeDir, }; const targetRoot = adapter.resolveRoot(planningInput); - - return modules.flatMap(module => { + const entries = modules.flatMap((module, moduleIndex) => { const paths = Array.isArray(module.paths) ? module.paths : []; return paths .filter(p => !isForeignPlatformPath(p, adapter.target)) - .flatMap(sourceRelativePath => { - if (sourceRelativePath === 'rules') { - return createFlatRuleOperations({ - moduleId: module.id, - repoRoot, - sourceRelativePath, - destinationDir: path.join(targetRoot, 'rules'), - }); - } + .map((sourceRelativePath, pathIndex) => ({ + module, + sourceRelativePath, + moduleIndex, + pathIndex, + })); + }).sort((left, right) => { + const getPriority = value => { + if (value === 'rules') { + return 0; + } - return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)]; + if (value === '.cursor') { + return 1; + } + + return 2; + }; + + const leftPriority = getPriority(left.sourceRelativePath); + const rightPriority = getPriority(right.sourceRelativePath); + if (leftPriority !== rightPriority) { + return leftPriority - rightPriority; + } + + if (left.moduleIndex !== right.moduleIndex) { + return left.moduleIndex - right.moduleIndex; + } + + return left.pathIndex - right.pathIndex; + }); + + function takeUniqueOperations(operations) { + return operations.filter(operation => { + if (!operation || !operation.destinationPath) { + return false; + } + + if (seenDestinationPaths.has(operation.destinationPath)) { + return false; + } + + seenDestinationPaths.add(operation.destinationPath); + return true; + }); + } + + return entries.flatMap(({ module, sourceRelativePath }) => { + if (sourceRelativePath === 'rules') { + return takeUniqueOperations(createFlatRuleOperations({ + moduleId: module.id, + repoRoot, + sourceRelativePath, + destinationDir: path.join(targetRoot, 'rules'), + destinationNameTransform: toCursorRuleFileName, + })); + } + + if (sourceRelativePath === '.cursor') { + const cursorRoot = path.join(repoRoot, '.cursor'); + if (!fs.existsSync(cursorRoot) || !fs.statSync(cursorRoot).isDirectory()) { + return []; + } + + const childOperations = fs.readdirSync(cursorRoot, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)) + .filter(entry => entry.name !== 'rules') + .map(entry => createManagedOperation({ + moduleId: module.id, + sourceRelativePath: path.join('.cursor', entry.name), + destinationPath: path.join(targetRoot, entry.name), + strategy: 'preserve-relative-path', + })); + + const ruleOperations = createFlatRuleOperations({ + moduleId: module.id, + repoRoot, + sourceRelativePath: '.cursor/rules', + destinationDir: path.join(targetRoot, 'rules'), + destinationNameTransform: toCursorRuleFileName, }); + + return takeUniqueOperations([...childOperations, ...ruleOperations]); + } + + return takeUniqueOperations([ + adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput), + ]); }); }, }); diff --git a/scripts/lib/install-targets/helpers.js b/scripts/lib/install-targets/helpers.js index fd959aa7..a39506e1 100644 --- a/scripts/lib/install-targets/helpers.js +++ b/scripts/lib/install-targets/helpers.js @@ -181,7 +181,13 @@ function createNamespacedFlatRuleOperations(adapter, moduleId, sourceRelativePat return operations; } -function createFlatRuleOperations({ moduleId, repoRoot, sourceRelativePath, destinationDir }) { +function createFlatRuleOperations({ + moduleId, + repoRoot, + sourceRelativePath, + destinationDir, + destinationNameTransform, +}) { const normalizedSourcePath = normalizeRelativePath(sourceRelativePath); const sourceRoot = path.join(repoRoot || '', normalizedSourcePath); @@ -201,19 +207,33 @@ function createFlatRuleOperations({ moduleId, repoRoot, sourceRelativePath, dest if (entry.isDirectory()) { const relativeFiles = listRelativeFiles(entryPath); for (const relativeFile of relativeFiles) { - const flattenedFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`; + const defaultFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`; + const sourceRelativeFile = path.join(normalizedSourcePath, namespace, relativeFile); + const flattenedFileName = typeof destinationNameTransform === 'function' + ? destinationNameTransform(defaultFileName, sourceRelativeFile) + : defaultFileName; + if (!flattenedFileName) { + continue; + } operations.push(createManagedOperation({ moduleId, - sourceRelativePath: path.join(normalizedSourcePath, namespace, relativeFile), + sourceRelativePath: sourceRelativeFile, destinationPath: path.join(destinationDir, flattenedFileName), strategy: 'flatten-copy', })); } } else if (entry.isFile()) { + const sourceRelativeFile = path.join(normalizedSourcePath, entry.name); + const destinationFileName = typeof destinationNameTransform === 'function' + ? destinationNameTransform(entry.name, sourceRelativeFile) + : entry.name; + if (!destinationFileName) { + continue; + } operations.push(createManagedOperation({ moduleId, - sourceRelativePath: path.join(normalizedSourcePath, entry.name), - destinationPath: path.join(destinationDir, entry.name), + sourceRelativePath: sourceRelativeFile, + destinationPath: path.join(destinationDir, destinationFileName), strategy: 'flatten-copy', })); } diff --git a/tests/hooks/mcp-health-check.test.js b/tests/hooks/mcp-health-check.test.js index 04d4a7b5..92a9804a 100644 --- a/tests/hooks/mcp-health-check.test.js +++ b/tests/hooks/mcp-health-check.test.js @@ -6,6 +6,8 @@ const assert = require('assert'); const fs = require('fs'); +const http = require('http'); +const https = require('https'); const os = require('os'); const path = require('path'); const { spawn, spawnSync } = require('child_process'); @@ -109,6 +111,39 @@ function waitForFile(filePath, timeoutMs = 5000) { } throw new Error(`Timed out waiting for ${filePath}`); } + +function waitForHttpReady(urlString, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + const { protocol } = new URL(urlString); + const client = protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + const attempt = () => { + const req = client.request(urlString, { method: 'GET' }, res => { + res.resume(); + resolve(); + }); + + req.setTimeout(250, () => { + req.destroy(new Error('timeout')); + }); + + req.on('error', error => { + if (Date.now() >= deadline) { + reject(new Error(`Timed out waiting for ${urlString}: ${error.message}`)); + return; + } + + setTimeout(attempt, 25); + }); + + req.end(); + }; + + attempt(); + }); +} + async function runTests() { console.log('\n=== Testing mcp-health-check.js ===\n'); @@ -329,6 +364,7 @@ async function runTests() { try { const port = waitForFile(portFile).trim(); + await waitForHttpReady(`http://127.0.0.1:${port}/mcp`); writeConfig(configPath, { mcpServers: { @@ -391,6 +427,7 @@ async function runTests() { try { const port = waitForFile(portFile).trim(); + await waitForHttpReady(`http://127.0.0.1:${port}/mcp`); writeConfig(configPath, { mcpServers: { diff --git a/tests/lib/install-manifests.test.js b/tests/lib/install-manifests.test.js index a7cbba79..ed0d7c73 100644 --- a/tests/lib/install-manifests.test.js +++ b/tests/lib/install-manifests.test.js @@ -116,10 +116,19 @@ function runTests() { assert.ok(plan.operations.length > 0, 'Should include scaffold operations'); assert.ok( plan.operations.some(operation => ( - operation.sourceRelativePath === '.cursor' - && operation.strategy === 'sync-root-children' + operation.sourceRelativePath === '.cursor/hooks.json' + && operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json') + && operation.strategy === 'preserve-relative-path' )), - 'Should flatten the native cursor root' + 'Should preserve non-rule Cursor platform files' + ); + assert.ok( + plan.operations.some(operation => ( + operation.sourceRelativePath === 'rules/common/agents.md' + && operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.mdc') + && operation.strategy === 'flatten-copy' + )), + 'Should produce Cursor .mdc rules while preferring rules-core over duplicate platform copies' ); })) passed++; else failed++; diff --git a/tests/lib/install-targets.test.js b/tests/lib/install-targets.test.js index 35c22765..d34fbcfa 100644 --- a/tests/lib/install-targets.test.js +++ b/tests/lib/install-targets.test.js @@ -90,20 +90,22 @@ function runTests() { assert.strictEqual(plan.targetRoot, path.join(projectRoot, '.cursor')); assert.strictEqual(plan.installStatePath, path.join(projectRoot, '.cursor', 'ecc-install-state.json')); - const flattened = plan.operations.find(operation => operation.sourceRelativePath === '.cursor'); + const hooksJson = plan.operations.find(operation => ( + normalizedRelativePath(operation.sourceRelativePath) === '.cursor/hooks.json' + )); const preserved = plan.operations.find(operation => ( normalizedRelativePath(operation.sourceRelativePath) === '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(hooksJson, 'Should preserve non-rule Cursor platform config files'); + assert.strictEqual(hooksJson.strategy, 'preserve-relative-path'); + assert.strictEqual(hooksJson.destinationPath, path.join(projectRoot, '.cursor', 'hooks.json')); 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') + path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.mdc') ); })) passed++; else failed++; @@ -126,16 +128,16 @@ function runTests() { assert.ok( plan.operations.some(operation => ( normalizedRelativePath(operation.sourceRelativePath) === 'rules/common/coding-style.md' - && operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.md') + && operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.mdc') )), - 'Should flatten common rules into namespaced files' + 'Should flatten common rules into namespaced .mdc files' ); assert.ok( plan.operations.some(operation => ( normalizedRelativePath(operation.sourceRelativePath) === 'rules/typescript/testing.md' - && operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'typescript-testing.md') + && operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'typescript-testing.mdc') )), - 'Should flatten language rules into namespaced files' + 'Should flatten language rules into namespaced .mdc files' ); assert.ok( !plan.operations.some(operation => ( @@ -143,6 +145,132 @@ function runTests() { )), 'Should not preserve nested rule directories for cursor installs' ); + assert.ok( + !plan.operations.some(operation => ( + operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.md') + )), + 'Should not emit .md Cursor rule files' + ); + assert.ok( + !plan.operations.some(operation => ( + normalizedRelativePath(operation.sourceRelativePath) === 'rules/README.md' + )), + 'Should not install Cursor README docs as runtime rule files' + ); + assert.ok( + !plan.operations.some(operation => ( + normalizedRelativePath(operation.sourceRelativePath) === 'rules/zh/README.md' + )), + 'Should not flatten localized README docs into Cursor rule files' + ); + })) passed++; else failed++; + + if (test('plans cursor platform rule files as .mdc and excludes rule README docs', () => { + const repoRoot = path.join(__dirname, '..', '..'); + const projectRoot = '/workspace/app'; + + const plan = planInstallTargetScaffold({ + target: 'cursor', + repoRoot, + projectRoot, + modules: [ + { + id: 'platform-configs', + paths: ['.cursor'], + }, + ], + }); + + assert.ok( + plan.operations.some(operation => ( + normalizedRelativePath(operation.sourceRelativePath) === '.cursor/rules/common-agents.md' + && operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.mdc') + )), + 'Should rename Cursor platform rule files to .mdc' + ); + assert.ok( + !plan.operations.some(operation => ( + operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.md') + )), + 'Should not preserve .md Cursor platform rule files' + ); + assert.ok( + plan.operations.some(operation => ( + normalizedRelativePath(operation.sourceRelativePath) === '.cursor/hooks.json' + && operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json') + )), + 'Should preserve non-rule Cursor platform config files' + ); + assert.ok( + !plan.operations.some(operation => ( + operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'README.mdc') + )), + 'Should not emit Cursor rule README docs as .mdc files' + ); + })) passed++; else failed++; + + if (test('deduplicates cursor rule destinations when rules-core and platform-configs overlap', () => { + const repoRoot = path.join(__dirname, '..', '..'); + const projectRoot = '/workspace/app'; + + const plan = planInstallTargetScaffold({ + target: 'cursor', + repoRoot, + projectRoot, + modules: [ + { + id: 'rules-core', + paths: ['rules'], + }, + { + id: 'platform-configs', + paths: ['.cursor'], + }, + ], + }); + + const commonAgentsDestinations = plan.operations.filter(operation => ( + operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.mdc') + )); + + assert.strictEqual(commonAgentsDestinations.length, 1, 'Should keep only one common-agents.mdc operation'); + assert.strictEqual( + normalizedRelativePath(commonAgentsDestinations[0].sourceRelativePath), + 'rules/common/agents.md', + 'Should prefer rules-core when cursor platform rules would collide' + ); + })) passed++; else failed++; + + if (test('prefers native cursor hooks when hooks-runtime and platform-configs overlap', () => { + const repoRoot = path.join(__dirname, '..', '..'); + const projectRoot = '/workspace/app'; + + const plan = planInstallTargetScaffold({ + target: 'cursor', + repoRoot, + projectRoot, + modules: [ + { + id: 'hooks-runtime', + paths: ['hooks', 'scripts/hooks', 'scripts/lib'], + }, + { + id: 'platform-configs', + paths: ['.cursor'], + }, + ], + }); + + const hooksDestinations = plan.operations.filter(operation => ( + operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks') + )); + + assert.strictEqual(hooksDestinations.length, 1, 'Should keep only one .cursor/hooks scaffold operation'); + assert.strictEqual( + normalizedRelativePath(hooksDestinations[0].sourceRelativePath), + '.cursor/hooks', + 'Should prefer native Cursor hooks over generic hooks-runtime hooks' + ); })) passed++; else failed++; if (test('plans antigravity remaps for workflows, skills, and flat rules', () => { diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 06cc6cbb..92f733d8 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -130,8 +130,11 @@ function runTests() { const result = run(['--target', 'cursor', 'typescript'], { cwd: projectDir, homeDir }); assert.strictEqual(result.code, 0, result.stderr); - assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-coding-style.md'))); - assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'typescript-testing.md'))); + assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-coding-style.mdc'))); + assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'typescript-testing.mdc'))); + assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.mdc'))); + assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md'))); + assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'README.mdc'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'agents', 'architect.md'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'commands', 'plan.md'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json'))); @@ -304,7 +307,8 @@ function runTests() { }); assert.strictEqual(result.code, 0, result.stderr); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json'))); - assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md'))); + assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.mdc'))); + assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md'))); const state = readJson(path.join(projectDir, '.cursor', 'ecc-install-state.json')); assert.strictEqual(state.request.profile, null);