diff --git a/README.md b/README.md index e51afcf5..84f39f6a 100644 --- a/README.md +++ b/README.md @@ -155,16 +155,24 @@ Get up and running in under 2 minutes: git clone https://github.com/affaan-m/everything-claude-code.git cd everything-claude-code -# Recommended: use the installer (handles common + language rules safely) +# macOS/Linux ./install.sh typescript # or python or golang or swift or php -# You can pass multiple languages: # ./install.sh typescript python golang swift php -# or target cursor: # ./install.sh --target cursor typescript -# or target antigravity: # ./install.sh --target antigravity typescript ``` +```powershell +# Windows PowerShell +.\install.ps1 typescript # or python or golang or swift or php +# .\install.ps1 typescript python golang swift php +# .\install.ps1 --target cursor typescript +# .\install.ps1 --target antigravity typescript + +# npm-installed compatibility entrypoint also works cross-platform +npx ecc-install typescript +``` + For manual install instructions see the README in the `rules/` folder. ### Step 3: Start Using @@ -875,11 +883,17 @@ ECC provides **full Cursor IDE support** with hooks, rules, agents, skills, comm ### Quick Start (Cursor) ```bash -# Install for your language(s) +# macOS/Linux ./install.sh --target cursor typescript ./install.sh --target cursor python golang swift php ``` +```powershell +# Windows PowerShell +.\install.ps1 --target cursor typescript +.\install.ps1 --target cursor python golang swift php +``` + ### What's Included | Component | Count | Details | diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 00000000..7a5af5bf --- /dev/null +++ b/install.ps1 @@ -0,0 +1,38 @@ +#!/usr/bin/env pwsh +# install.ps1 — Windows-native entrypoint for the ECC installer. +# +# This wrapper resolves the real repo/package root when invoked through a +# symlinked path, then delegates to the Node-based installer runtime. + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptPath = $PSCommandPath + +while ($true) { + $item = Get-Item -LiteralPath $scriptPath -Force + if (-not $item.LinkType) { + break + } + + $targetPath = $item.Target + if ($targetPath -is [array]) { + $targetPath = $targetPath[0] + } + + if (-not $targetPath) { + break + } + + if (-not [System.IO.Path]::IsPathRooted($targetPath)) { + $targetPath = Join-Path -Path $item.DirectoryName -ChildPath $targetPath + } + + $scriptPath = [System.IO.Path]::GetFullPath($targetPath) +} + +$scriptDir = Split-Path -Parent $scriptPath +$installerScript = Join-Path -Path (Join-Path -Path $scriptDir -ChildPath 'scripts') -ChildPath 'install-apply.js' + +& node $installerScript @args +exit $LASTEXITCODE diff --git a/package-lock.json b/package-lock.json index 2e18fa41..cfb1b7aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ }, "bin": { "ecc": "scripts/ecc.js", - "ecc-install": "install.sh" + "ecc-install": "scripts/install-apply.js" }, "devDependencies": { "@eslint/js": "^9.39.2", diff --git a/package.json b/package.json index 96b5085c..4cb62772 100644 --- a/package.json +++ b/package.json @@ -89,11 +89,12 @@ ".claude-plugin/plugin.json", ".claude-plugin/README.md", "install.sh", + "install.ps1", "llms.txt" ], "bin": { "ecc": "scripts/ecc.js", - "ecc-install": "install.sh" + "ecc-install": "scripts/install-apply.js" }, "scripts": { "postinstall": "echo '\\n ecc-universal installed!\\n Run: npx ecc typescript\\n Compat: npx ecc-install typescript\\n Docs: https://github.com/affaan-m/everything-claude-code\\n'", diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index dfb9c762..a8f04f1b 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -4,7 +4,6 @@ const path = require('path'); const { resolveInstallPlan, loadInstallManifests } = require('./install-manifests'); const { readInstallState, writeInstallState } = require('./install-state'); const { - createLegacyInstallPlan, createManifestInstallPlan, } = require('./install-executor'); const { diff --git a/scripts/lib/install/config.js b/scripts/lib/install/config.js index 3858a646..2ff8f8a1 100644 --- a/scripts/lib/install/config.js +++ b/scripts/lib/install/config.js @@ -44,7 +44,7 @@ function resolveInstallConfigPath(configPath, options = {}) { const cwd = options.cwd || process.cwd(); return path.isAbsolute(configPath) ? configPath - : path.resolve(cwd, configPath); + : path.normalize(path.join(cwd, configPath)); } function loadInstallConfig(configPath, options = {}) { diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 717cd90a..4393c07d 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -10,6 +10,20 @@ const fs = require('fs'); const os = require('os'); const { spawn, spawnSync } = require('child_process'); +function toBashPath(filePath) { + if (process.platform !== 'win32') { + return filePath; + } + + return String(filePath) + .replace(/^([A-Za-z]):/, (_, driveLetter) => `/mnt/${driveLetter.toLowerCase()}`) + .replace(/\\/g, '/'); +} + +function sleepMs(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + // Test helper function test(name, fn) { try { @@ -65,7 +79,7 @@ function runScript(scriptPath, input = '', env = {}) { function runShellScript(scriptPath, args = [], input = '', env = {}, cwd = process.cwd()) { return new Promise((resolve, reject) => { - const proc = spawn('bash', [scriptPath, ...args], { + const proc = spawn('bash', [toBashPath(scriptPath), ...args], { cwd, env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'] @@ -93,7 +107,19 @@ function createTestDir() { // Clean up test directory function cleanupTestDir(testDir) { - fs.rmSync(testDir, { recursive: true, force: true }); + const retryableCodes = new Set(['EPERM', 'EBUSY', 'ENOTEMPTY']); + + for (let attempt = 0; attempt < 5; attempt++) { + try { + fs.rmSync(testDir, { recursive: true, force: true }); + return; + } catch (error) { + if (!retryableCodes.has(error.code) || attempt === 4) { + throw error; + } + sleepMs(50 * (attempt + 1)); + } + } } function createCommandShim(binDir, baseName, logFile) { @@ -2253,7 +2279,7 @@ async function runTests() { if ( await asyncTest('detect-project exports the resolved Python command for downstream scripts', async () => { const detectProjectPath = path.join(__dirname, '..', '..', 'skills', 'continuous-learning-v2', 'scripts', 'detect-project.sh'); - const shellCommand = [`source "${detectProjectPath}" >/dev/null 2>&1`, 'printf "%s\\n" "${CLV2_PYTHON_CMD:-}"'].join('; '); + const shellCommand = [`source "${toBashPath(detectProjectPath)}" >/dev/null 2>&1`, 'printf "%s\\n" "${CLV2_PYTHON_CMD:-}"'].join('; '); const shell = process.platform === 'win32' ? 'bash' : 'bash'; const proc = spawn(shell, ['-lc', shellCommand], { @@ -2292,14 +2318,14 @@ async function runTests() { spawnSync('git', ['remote', 'add', 'origin', 'https://github.com/example/ecc-test.git'], { cwd: repoDir, stdio: 'ignore' }); const shellCommand = [ - `cd "${repoDir}"`, - `source "${detectProjectPath}" >/dev/null 2>&1`, + `cd "${toBashPath(repoDir)}"`, + `source "${toBashPath(detectProjectPath)}" >/dev/null 2>&1`, 'printf "%s\\n" "$PROJECT_ID"', 'printf "%s\\n" "$PROJECT_DIR"' ].join('; '); const proc = spawn('bash', ['-lc', shellCommand], { - env: { ...process.env, HOME: homeDir }, + env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir }, stdio: ['ignore', 'pipe', 'pipe'] }); @@ -2357,6 +2383,7 @@ async function runTests() { try { const result = await runShellScript(observePath, ['post'], payload, { HOME: homeDir, + USERPROFILE: homeDir, CLAUDE_PROJECT_DIR: projectDir }, projectDir); diff --git a/tests/lib/install-targets.test.js b/tests/lib/install-targets.test.js index 1357fc3e..23de2633 100644 --- a/tests/lib/install-targets.test.js +++ b/tests/lib/install-targets.test.js @@ -11,6 +11,10 @@ const { planInstallTargetScaffold, } = require('../../scripts/lib/install-targets/registry'); +function normalizedRelativePath(value) { + return String(value || '').replace(/\\/g, '/'); +} + function test(name, fn) { try { fn(); @@ -86,7 +90,7 @@ function runTests() { const flattened = plan.operations.find(operation => operation.sourceRelativePath === '.cursor'); const preserved = plan.operations.find(operation => ( - operation.sourceRelativePath === path.join('rules', 'common', 'coding-style.md') + normalizedRelativePath(operation.sourceRelativePath) === 'rules/common/coding-style.md' )); assert.ok(flattened, 'Should include .cursor scaffold operation'); @@ -119,14 +123,14 @@ function runTests() { assert.ok( plan.operations.some(operation => ( - operation.sourceRelativePath === path.join('rules', 'common', 'coding-style.md') + normalizedRelativePath(operation.sourceRelativePath) === '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') + normalizedRelativePath(operation.sourceRelativePath) === 'rules/typescript/testing.md' && operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'typescript-testing.md') )), 'Should flatten language rules into namespaced files' @@ -179,7 +183,7 @@ function runTests() { ); assert.ok( plan.operations.some(operation => ( - operation.sourceRelativePath === path.join('rules', 'common', 'coding-style.md') + normalizedRelativePath(operation.sourceRelativePath) === 'rules/common/coding-style.md' && operation.destinationPath === path.join(projectRoot, '.agent', 'rules', 'common-coding-style.md') )), 'Should flatten common rules for antigravity' diff --git a/tests/lib/session-adapters.test.js b/tests/lib/session-adapters.test.js index 32046f9c..529ba3fe 100644 --- a/tests/lib/session-adapters.test.js +++ b/tests/lib/session-adapters.test.js @@ -34,7 +34,9 @@ function test(name, fn) { function withHome(homeDir, fn) { const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; try { fn(); @@ -44,6 +46,12 @@ function withHome(homeDir, fn) { } else { delete process.env.HOME; } + + if (typeof previousUserProfile === 'string') { + process.env.USERPROFILE = previousUserProfile; + } else { + delete process.env.USERPROFILE; + } } } diff --git a/tests/run-all.js b/tests/run-all.js index 6b7ccc83..697575e0 100644 --- a/tests/run-all.js +++ b/tests/run-all.js @@ -64,13 +64,14 @@ let totalTests = 0; for (const testFile of testFiles) { const testPath = path.join(testsDir, testFile); + const displayPath = testFile.split(path.sep).join('/'); if (!fs.existsSync(testPath)) { - console.log(`⚠ Skipping ${testFile} (file not found)`); + console.log(`⚠ Skipping ${displayPath} (file not found)`); continue; } - console.log(`\n━━━ Running ${testFile} ━━━`); + console.log(`\n━━━ Running ${displayPath} ━━━`); const result = spawnSync('node', [testPath], { encoding: 'utf8', @@ -93,13 +94,13 @@ for (const testFile of testFiles) { if (failedMatch) totalFailed += parseInt(failedMatch[1], 10); if (result.error) { - console.log(`✗ ${testFile} failed to start: ${result.error.message}`); + console.log(`✗ ${displayPath} failed to start: ${result.error.message}`); totalFailed += failedMatch ? 0 : 1; continue; } if (result.status !== 0) { - console.log(`✗ ${testFile} exited with status ${result.status}`); + console.log(`✗ ${displayPath} exited with status ${result.status}`); totalFailed += failedMatch ? 0 : 1; } } diff --git a/tests/scripts/ecc.test.js b/tests/scripts/ecc.test.js index 1f92b3a9..85cb6dc5 100644 --- a/tests/scripts/ecc.test.js +++ b/tests/scripts/ecc.test.js @@ -11,13 +11,25 @@ const { spawnSync } = require('child_process'); const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'ecc.js'); function runCli(args, options = {}) { + const envOverrides = { + ...(options.env || {}), + }; + + if (typeof envOverrides.HOME === 'string' && !('USERPROFILE' in envOverrides)) { + envOverrides.USERPROFILE = envOverrides.HOME; + } + + if (typeof envOverrides.USERPROFILE === 'string' && !('HOME' in envOverrides)) { + envOverrides.HOME = envOverrides.USERPROFILE; + } + return spawnSync('node', [SCRIPT, ...args], { encoding: 'utf8', cwd: options.cwd || process.cwd(), maxBuffer: 10 * 1024 * 1024, env: { ...process.env, - ...(options.env || {}), + ...envOverrides, }, }); } diff --git a/tests/scripts/install-ps1.test.js b/tests/scripts/install-ps1.test.js new file mode 100644 index 00000000..d5e1d077 --- /dev/null +++ b/tests/scripts/install-ps1.test.js @@ -0,0 +1,117 @@ +/** + * Tests for install.ps1 wrapper delegation + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync, spawnSync } = require('child_process'); + +const SCRIPT = path.join(__dirname, '..', '..', 'install.ps1'); +const PACKAGE_JSON = path.join(__dirname, '..', '..', 'package.json'); + +function createTempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function cleanup(dirPath) { + fs.rmSync(dirPath, { recursive: true, force: true }); +} + +function resolvePowerShellCommand() { + const candidates = process.platform === 'win32' + ? ['powershell.exe', 'pwsh.exe', 'pwsh'] + : ['pwsh']; + + for (const candidate of candidates) { + const result = spawnSync(candidate, ['-NoLogo', '-NoProfile', '-Command', '$PSVersionTable.PSVersion.ToString()'], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 5000, + }); + + if (!result.error && result.status === 0) { + return candidate; + } + } + + return null; +} + +function run(powerShellCommand, args = [], options = {}) { + const env = { + ...process.env, + HOME: options.homeDir || process.env.HOME, + USERPROFILE: options.homeDir || process.env.USERPROFILE, + }; + + try { + const stdout = execFileSync(powerShellCommand, ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', SCRIPT, ...args], { + cwd: options.cwd, + env, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000, + }); + + return { code: 0, stdout, stderr: '' }; + } catch (error) { + return { + code: error.status || 1, + stdout: error.stdout || '', + stderr: error.stderr || '', + }; + } +} + +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (error) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function runTests() { + console.log('\n=== Testing install.ps1 ===\n'); + + let passed = 0; + let failed = 0; + const powerShellCommand = resolvePowerShellCommand(); + + if (test('publishes ecc-install through the Node installer runtime for cross-platform npm usage', () => { + const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON, 'utf8')); + assert.strictEqual(packageJson.bin['ecc-install'], 'scripts/install-apply.js'); + })) passed++; else failed++; + + if (!powerShellCommand) { + console.log(' - skipped delegation test; PowerShell is not available in PATH'); + } else if (test('delegates to the Node installer and preserves dry-run output', () => { + const homeDir = createTempDir('install-ps1-home-'); + const projectDir = createTempDir('install-ps1-project-'); + + try { + const result = run(powerShellCommand, ['--target', 'cursor', '--dry-run', 'typescript'], { + cwd: projectDir, + homeDir, + }); + + assert.strictEqual(result.code, 0, result.stderr); + assert.ok(result.stdout.includes('Dry-run install plan')); + assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json'))); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/scripts/install-sh.test.js b/tests/scripts/install-sh.test.js index 56e4333a..0ed881b9 100644 --- a/tests/scripts/install-sh.test.js +++ b/tests/scripts/install-sh.test.js @@ -61,6 +61,12 @@ function runTests() { let passed = 0; let failed = 0; + if (process.platform === 'win32') { + console.log(' - skipped on Windows; install.ps1 covers the native wrapper path'); + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(0); + } + if (test('delegates to the Node installer and preserves dry-run output', () => { const homeDir = createTempDir('install-sh-home-'); const projectDir = createTempDir('install-sh-project-'); diff --git a/tests/scripts/session-inspect.test.js b/tests/scripts/session-inspect.test.js index aeb59fe7..21b3ac1d 100644 --- a/tests/scripts/session-inspect.test.js +++ b/tests/scripts/session-inspect.test.js @@ -13,6 +13,18 @@ const { getFallbackSessionRecordingPath } = require('../../scripts/lib/session-a const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'session-inspect.js'); function run(args = [], options = {}) { + const envOverrides = { + ...(options.env || {}) + }; + + if (typeof envOverrides.HOME === 'string' && !('USERPROFILE' in envOverrides)) { + envOverrides.USERPROFILE = envOverrides.HOME; + } + + if (typeof envOverrides.USERPROFILE === 'string' && !('HOME' in envOverrides)) { + envOverrides.HOME = envOverrides.USERPROFILE; + } + try { const stdout = execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8', @@ -21,7 +33,7 @@ function run(args = [], options = {}) { cwd: options.cwd || process.cwd(), env: { ...process.env, - ...(options.env || {}) + ...envOverrides } }); return { code: 0, stdout, stderr: '' };