From 018c311bcff2c136decbbfb194eb81d4ac0de553 Mon Sep 17 00:00:00 2001 From: wzc <1215660855@qq.com> Date: Wed, 9 Sep 2026 16:24:00 +0800 Subject: [PATCH] fix(install): preflight Node.js and npm prerequisites in install.ps1 - Check for Node.js presence and major version (>= 18) before invoking installer - Check for npm presence when node_modules is missing during git-clone installs - Use [Console]::Error.WriteLine to ensure clean stderr output and preserve exit codes - Add regression tests in tests/scripts/install-ps1.test.js covering missing and outdated Node.js environments --- install.ps1 | 18 ++++++- tests/scripts/install-ps1.test.js | 89 +++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/install.ps1 b/install.ps1 index 752ac2ec4..975460847 100644 --- a/install.ps1 +++ b/install.ps1 @@ -34,15 +34,31 @@ while ($true) { $scriptDir = Split-Path -Parent $scriptPath $installerScript = Join-Path -Path (Join-Path -Path $scriptDir -ChildPath 'scripts') -ChildPath 'install-apply.js' +# ponytail: preflight Node.js presence and version (>= 18) +if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + [Console]::Error.WriteLine('[ECC] Node.js is required but was not found in PATH. Please install Node.js 18 or newer: https://nodejs.org') + exit 1 +} + +$nodeVersion = (& node -v 2>$null | Select-Object -First 1) +if ($nodeVersion -match '^\s*v?(\d+)\.' -and [int]$Matches[1] -lt 18) { + [Console]::Error.WriteLine("[ECC] Node.js 18 or newer is required (found $nodeVersion). Please update Node.js: https://nodejs.org") + exit 1 +} + # Auto-install Node dependencies when running from a git clone $nodeModules = Join-Path -Path $scriptDir -ChildPath 'node_modules' if (-not (Test-Path -LiteralPath $nodeModules)) { + if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { + [Console]::Error.WriteLine('[ECC] npm is required to install dependencies but was not found in PATH.') + exit 1 + } Write-Host '[ECC] Installing dependencies...' Push-Location $scriptDir try { & npm install --no-audit --no-fund --loglevel=error if ($LASTEXITCODE -ne 0) { - Write-Error "npm install failed with exit code $LASTEXITCODE" + [Console]::Error.WriteLine("npm install failed with exit code $LASTEXITCODE") exit $LASTEXITCODE } } diff --git a/tests/scripts/install-ps1.test.js b/tests/scripts/install-ps1.test.js index 81fbdd102..a196870fd 100644 --- a/tests/scripts/install-ps1.test.js +++ b/tests/scripts/install-ps1.test.js @@ -49,6 +49,17 @@ function resolvePowerShellCommand() { }); if (!result.error && result.status === 0) { + try { + const whereCmd = process.platform === 'win32' ? 'where.exe' : 'which'; + const resolved = execFileSync(whereCmd, [candidate], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }) + .trim() + .split(/\r?\n/)[0]; + if (resolved && fs.existsSync(resolved)) { + return fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved); + } + } catch { + // fallback to candidate name if resolving full path fails + } return candidate; } } @@ -63,6 +74,19 @@ function run(powerShellCommand, args = [], options = {}) { USERPROFILE: options.homeDir || process.env.USERPROFILE, }; + if (options.env) { + for (const [key, val] of Object.entries(options.env)) { + if (key.toLowerCase() === 'path') { + for (const k of Object.keys(env)) { + if (k.toLowerCase() === 'path') { + delete env[k]; + } + } + } + env[key] = val; + } + } + try { const stdout = execFileSync(powerShellCommand, ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', SCRIPT, ...args], { cwd: options.cwd, @@ -160,6 +184,71 @@ function runTests() { ); })) passed++; else failed++; + if (!powerShellCommand) { + console.log(' - skipped missing Node.js preflight test; PowerShell is not available in PATH'); + } else if (test('rejects execution when Node.js is missing from PATH with actionable message', () => { + const isolatedDir = createTempDir('install-ps1-no-node-'); + try { + const pPath = [ + isolatedDir, + process.env.SystemRoot ? path.join(process.env.SystemRoot, 'System32') : '', + ].filter(Boolean).join(path.delimiter); + + const result = run(powerShellCommand, ['--help'], { + env: { + PATH: pPath, + SystemRoot: process.env.SystemRoot || 'C:\\Windows', + }, + }); + + assert.notStrictEqual(result.code, 0, 'installer should fail when node is absent'); + const combinedOutput = `${result.stdout}\n${result.stderr}`; + assert.ok( + combinedOutput.includes('[ECC] Node.js is required but was not found in PATH'), + `error output should explain missing Node.js requirement:\n${combinedOutput}` + ); + } finally { + cleanup(isolatedDir); + } + })) passed++; else failed++; + + if (!powerShellCommand) { + console.log(' - skipped outdated Node.js preflight test; PowerShell is not available in PATH'); + } else if (test('rejects execution when Node.js version is older than 18', () => { + const mockBinDir = createTempDir('install-ps1-mock-node-'); + try { + if (process.platform === 'win32') { + fs.writeFileSync(path.join(mockBinDir, 'node.cmd'), '@echo v16.20.0\r\n'); + } else { + const mockNode = path.join(mockBinDir, 'node'); + fs.writeFileSync(mockNode, '#!/bin/sh\necho v16.20.0\n'); + fs.chmodSync(mockNode, 0o755); + } + + const pPath = [ + mockBinDir, + process.env.SystemRoot ? path.join(process.env.SystemRoot, 'System32') : '', + ].filter(Boolean).join(path.delimiter); + + const result = run(powerShellCommand, ['--help'], { + env: { + PATH: pPath, + PATHEXT: process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD', + SystemRoot: process.env.SystemRoot || 'C:\\Windows', + }, + }); + + assert.notStrictEqual(result.code, 0, 'installer should fail when node version is < 18'); + const combinedOutput = `${result.stdout}\n${result.stderr}`; + assert.ok( + combinedOutput.includes('[ECC] Node.js 18 or newer is required (found v16.20.0)'), + `error output should explain Node.js version requirement:\n${combinedOutput}` + ); + } finally { + cleanup(mockBinDir); + } + })) passed++; else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); }