From 17a6ef4edbd50e8a1320f2ce1c903fa9c01f3e3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Haller=C3=B6s?= Date: Mon, 16 Mar 2026 21:35:17 +0100 Subject: [PATCH] Add PowerShell installer wrapper and update documentation (#532) * Add install.ps1 PowerShell wrapper and tests Add a Windows-native PowerShell wrapper (install.ps1) that resolves symlinks and delegates to the Node-based installer runtime. Update README with PowerShell usage examples and cross-platform npx entrypoint guidance. Point the ecc-install bin to the Node installer (scripts/install-apply.js) in package.json (and refresh package-lock), include install.ps1 in package files, and add tests: a new install-ps1.test.js and a tweak to install-sh.test.js to skip on Windows. These changes provide native Windows installer support while keeping npm-compatible cross-platform invocation. * Improve tests for Windows HOME/USERPROFILE Make tests more cross-platform by ensuring HOME and USERPROFILE are kept in sync and by normalizing test file paths for display. - tests/lib/session-adapters.test.js: set USERPROFILE when temporarily setting HOME and restore previous USERPROFILE on teardown. - tests/run-all.js: use a normalized displayPath (forward-slash separated) for logging and error messages so output is consistent across platforms. - tests/scripts/ecc.test.js & tests/scripts/session-inspect.test.js: build envOverrides from options.env and add HOME <-> USERPROFILE fallbacks so spawned child processes receive both variables when only one is provided. These changes prevent test failures and inconsistent logs on Windows where USERPROFILE is used instead of HOME. * Fix Windows paths and test flakiness Improve cross-platform behavior and test stability. - Remove unused createLegacyInstallPlan import from install-lifecycle.js. - Change resolveInstallConfigPath to use path.normalize(path.join(cwd, configPath)) to produce normalized relative paths. - Tests: add toBashPath and normalizedRelativePath helpers to normalize Windows paths for bash and comparisons. - Make cleanupTestDir retry rmSync on transient Windows errors (EPERM/EBUSY/ENOTEMPTY) with short backoff using sleepMs. - Ensure spawned test processes receive USERPROFILE and convert repo/detect paths to bash format when invoking bash. These changes reduce Windows-specific failures and flakiness in the test suite and tidy up a small unused import. --- README.md | 24 ++++-- install.ps1 | 38 +++++++++ package-lock.json | 2 +- package.json | 3 +- scripts/lib/install-lifecycle.js | 1 - scripts/lib/install/config.js | 2 +- tests/hooks/hooks.test.js | 39 +++++++-- tests/lib/install-targets.test.js | 12 ++- tests/lib/session-adapters.test.js | 8 ++ tests/run-all.js | 9 +- tests/scripts/ecc.test.js | 14 ++- tests/scripts/install-ps1.test.js | 117 ++++++++++++++++++++++++++ tests/scripts/install-sh.test.js | 6 ++ tests/scripts/session-inspect.test.js | 14 ++- 14 files changed, 264 insertions(+), 25 deletions(-) create mode 100644 install.ps1 create mode 100644 tests/scripts/install-ps1.test.js 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: '' };