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 1/5] 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); } From 5c78d38bab1a3e81ff944b34768bd7ae3436aa5f Mon Sep 17 00:00:00 2001 From: wzc <1215660855@qq.com> Date: Wed, 9 Sep 2026 16:34:27 +0800 Subject: [PATCH 2/5] fix(install): fail closed on unparseable Node version and make test env immutable - Reject unparseable or empty Node.js version output before major version check - Define minimumNodeMajor constant to eliminate duplicate version numbers - Build child test environments immutably without in-place mutation - Add regression test for unparseable Node.js version output --- install.ps1 | 12 ++++-- tests/scripts/install-ps1.test.js | 67 +++++++++++++++++++++++-------- 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/install.ps1 b/install.ps1 index 975460847..e6ca9be1d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -35,14 +35,20 @@ $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) +$minimumNodeMajor = 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') + [Console]::Error.WriteLine("[ECC] Node.js is required but was not found in PATH. Please install Node.js $minimumNodeMajor 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") +if (-not $nodeVersion -or $nodeVersion -notmatch '^\s*v?(\d+)\.') { + [Console]::Error.WriteLine("[ECC] Failed to determine Node.js version (found '$nodeVersion'). Node.js $minimumNodeMajor or newer is required: https://nodejs.org") + exit 1 +} + +if ([int]$Matches[1] -lt $minimumNodeMajor) { + [Console]::Error.WriteLine("[ECC] Node.js $minimumNodeMajor or newer is required (found $nodeVersion). Please update Node.js: https://nodejs.org") exit 1 } diff --git a/tests/scripts/install-ps1.test.js b/tests/scripts/install-ps1.test.js index a196870fd..557e23396 100644 --- a/tests/scripts/install-ps1.test.js +++ b/tests/scripts/install-ps1.test.js @@ -68,24 +68,21 @@ function resolvePowerShellCommand() { } function run(powerShellCommand, args = [], options = {}) { - const env = { + const baseEnv = { ...process.env, HOME: options.homeDir || process.env.HOME, 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; - } - } + const env = options.env + ? Object.fromEntries([ + ...Object.entries(baseEnv).filter(([k]) => { + const lowerKey = k.toLowerCase(); + return !Object.keys(options.env).some((overrideKey) => overrideKey.toLowerCase() === lowerKey); + }), + ...Object.entries(options.env), + ]) + : baseEnv; try { const stdout = execFileSync(powerShellCommand, ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', SCRIPT, ...args], { @@ -215,13 +212,14 @@ function runTests() { 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 UNSUPPORTED_NODE_VERSION = 'v16.20.0'; 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'); + fs.writeFileSync(path.join(mockBinDir, 'node.cmd'), `@echo ${UNSUPPORTED_NODE_VERSION}\r\n`); } else { const mockNode = path.join(mockBinDir, 'node'); - fs.writeFileSync(mockNode, '#!/bin/sh\necho v16.20.0\n'); + fs.writeFileSync(mockNode, `#!/bin/sh\necho ${UNSUPPORTED_NODE_VERSION}\n`); fs.chmodSync(mockNode, 0o755); } @@ -241,7 +239,7 @@ function runTests() { 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)'), + combinedOutput.includes(`[ECC] Node.js 18 or newer is required (found ${UNSUPPORTED_NODE_VERSION})`), `error output should explain Node.js version requirement:\n${combinedOutput}` ); } finally { @@ -249,6 +247,43 @@ function runTests() { } })) passed++; else failed++; + if (!powerShellCommand) { + console.log(' - skipped unparseable Node.js preflight test; PowerShell is not available in PATH'); + } else if (test('rejects execution when Node.js version is unparseable', () => { + const mockBinDir = createTempDir('install-ps1-invalid-node-'); + try { + if (process.platform === 'win32') { + fs.writeFileSync(path.join(mockBinDir, 'node.cmd'), '@echo unexpected-node-output\r\n'); + } else { + const mockNode = path.join(mockBinDir, 'node'); + fs.writeFileSync(mockNode, '#!/bin/sh\necho unexpected-node-output\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 unparseable'); + const combinedOutput = `${result.stdout}\n${result.stderr}`; + assert.ok( + combinedOutput.includes("Failed to determine Node.js version (found 'unexpected-node-output')"), + `error output should explain unparseable Node.js version:\n${combinedOutput}` + ); + } finally { + cleanup(mockBinDir); + } + })) passed++; else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } From 0352b0ef7bdd77e994f7ef0fd7f5305d4592d329 Mon Sep 17 00:00:00 2001 From: wzc <1215660855@qq.com> Date: Wed, 9 Sep 2026 16:43:27 +0800 Subject: [PATCH 3/5] chore: ensure pure ASCII in install.ps1 header comment --- install.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.ps1 b/install.ps1 index e6ca9be1d..96c249192 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,5 +1,5 @@ #!/usr/bin/env pwsh -# install.ps1 — Windows-native entrypoint for the ECC installer. +# 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. From f6eeb4698606f17fbaca938383e7fb67076c507a Mon Sep 17 00:00:00 2001 From: wzc <1215660855@qq.com> Date: Wed, 9 Sep 2026 16:49:44 +0800 Subject: [PATCH 4/5] test(install): add regression tests for npm prerequisites and extract constant --- tests/scripts/install-ps1.test.js | 107 ++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 4 deletions(-) diff --git a/tests/scripts/install-ps1.test.js b/tests/scripts/install-ps1.test.js index 557e23396..c53ad392a 100644 --- a/tests/scripts/install-ps1.test.js +++ b/tests/scripts/install-ps1.test.js @@ -85,7 +85,8 @@ function run(powerShellCommand, args = [], options = {}) { : baseEnv; try { - const stdout = execFileSync(powerShellCommand, ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', SCRIPT, ...args], { + const scriptToRun = options.script || SCRIPT; + const stdout = execFileSync(powerShellCommand, ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptToRun, ...args], { cwd: options.cwd, env, encoding: 'utf8', @@ -250,18 +251,20 @@ function runTests() { if (!powerShellCommand) { console.log(' - skipped unparseable Node.js preflight test; PowerShell is not available in PATH'); } else if (test('rejects execution when Node.js version is unparseable', () => { + const INVALID_NODE_VERSION_OUTPUT = 'unexpected-node-output'; const mockBinDir = createTempDir('install-ps1-invalid-node-'); try { if (process.platform === 'win32') { - fs.writeFileSync(path.join(mockBinDir, 'node.cmd'), '@echo unexpected-node-output\r\n'); + fs.writeFileSync(path.join(mockBinDir, 'node.cmd'), `@echo ${INVALID_NODE_VERSION_OUTPUT}\r\n`); } else { const mockNode = path.join(mockBinDir, 'node'); - fs.writeFileSync(mockNode, '#!/bin/sh\necho unexpected-node-output\n'); + fs.writeFileSync(mockNode, `#!/bin/sh\necho ${INVALID_NODE_VERSION_OUTPUT}\n`); fs.chmodSync(mockNode, 0o755); } const pPath = [ mockBinDir, + path.dirname(powerShellCommand), process.env.SystemRoot ? path.join(process.env.SystemRoot, 'System32') : '', ].filter(Boolean).join(path.delimiter); @@ -276,7 +279,7 @@ function runTests() { assert.notStrictEqual(result.code, 0, 'installer should fail when node version is unparseable'); const combinedOutput = `${result.stdout}\n${result.stderr}`; assert.ok( - combinedOutput.includes("Failed to determine Node.js version (found 'unexpected-node-output')"), + combinedOutput.includes(`Failed to determine Node.js version (found '${INVALID_NODE_VERSION_OUTPUT}')`), `error output should explain unparseable Node.js version:\n${combinedOutput}` ); } finally { @@ -284,6 +287,102 @@ function runTests() { } })) passed++; else failed++; + if (!powerShellCommand) { + console.log(' - skipped missing npm preflight test; PowerShell is not available in PATH'); + } else if (test('rejects execution when node_modules is missing and npm is absent from PATH', () => { + const isolatedDir = createTempDir('install-ps1-no-npm-'); + const mockBinDir = createTempDir('install-ps1-mock-node-'); + try { + const tempScript = path.join(isolatedDir, 'install.ps1'); + fs.copyFileSync(SCRIPT, tempScript); + + const VALID_NODE_VERSION = 'v18.0.0'; + if (process.platform === 'win32') { + fs.writeFileSync(path.join(mockBinDir, 'node.cmd'), `@echo ${VALID_NODE_VERSION}\r\n`); + } else { + const mockNode = path.join(mockBinDir, 'node'); + fs.writeFileSync(mockNode, `#!/bin/sh\necho ${VALID_NODE_VERSION}\n`); + fs.chmodSync(mockNode, 0o755); + } + + const pPath = [ + mockBinDir, + path.dirname(powerShellCommand), + process.env.SystemRoot ? path.join(process.env.SystemRoot, 'System32') : '', + ].filter(Boolean).join(path.delimiter); + + const result = run(powerShellCommand, ['--help'], { + script: tempScript, + 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_modules is absent and npm is missing'); + const combinedOutput = `${result.stdout}\n${result.stderr}`; + assert.ok( + combinedOutput.includes('[ECC] npm is required to install dependencies but was not found in PATH'), + `error output should explain missing npm requirement:\n${combinedOutput}` + ); + } finally { + cleanup(mockBinDir); + cleanup(isolatedDir); + } + })) passed++; else failed++; + + if (!powerShellCommand) { + console.log(' - skipped npm install failure test; PowerShell is not available in PATH'); + } else if (test('propagates exit code and error when npm install fails', () => { + const isolatedDir = createTempDir('install-ps1-npm-fail-'); + const mockBinDir = createTempDir('install-ps1-mock-bin-'); + try { + const tempScript = path.join(isolatedDir, 'install.ps1'); + fs.copyFileSync(SCRIPT, tempScript); + + const VALID_NODE_VERSION = 'v18.0.0'; + const NPM_FAILURE_CODE = 42; + if (process.platform === 'win32') { + fs.writeFileSync(path.join(mockBinDir, 'node.cmd'), `@echo ${VALID_NODE_VERSION}\r\n`); + fs.writeFileSync(path.join(mockBinDir, 'npm.cmd'), `@exit /b ${NPM_FAILURE_CODE}\r\n`); + } else { + const mockNode = path.join(mockBinDir, 'node'); + fs.writeFileSync(mockNode, `#!/bin/sh\necho ${VALID_NODE_VERSION}\n`); + fs.chmodSync(mockNode, 0o755); + + const mockNpm = path.join(mockBinDir, 'npm'); + fs.writeFileSync(mockNpm, `#!/bin/sh\nexit ${NPM_FAILURE_CODE}\n`); + fs.chmodSync(mockNpm, 0o755); + } + + const pPath = [ + mockBinDir, + path.dirname(powerShellCommand), + process.env.SystemRoot ? path.join(process.env.SystemRoot, 'System32') : '', + ].filter(Boolean).join(path.delimiter); + + const result = run(powerShellCommand, ['--help'], { + script: tempScript, + env: { + PATH: pPath, + PATHEXT: process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD', + SystemRoot: process.env.SystemRoot || 'C:\\Windows', + }, + }); + + assert.strictEqual(result.code, NPM_FAILURE_CODE, 'installer should propagate npm install exit code'); + const combinedOutput = `${result.stdout}\n${result.stderr}`; + assert.ok( + combinedOutput.includes(`npm install failed with exit code ${NPM_FAILURE_CODE}`), + `error output should report npm install failure:\n${combinedOutput}` + ); + } finally { + cleanup(mockBinDir); + cleanup(isolatedDir); + } + })) passed++; else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } From 91e31a032ec45bc281d28fef803587e5dd6d8f11 Mon Sep 17 00:00:00 2001 From: wzc <1215660855@qq.com> Date: Sun, 27 Sep 2026 14:08:33 +0800 Subject: [PATCH 5/5] fix(install): refine preflight comment and cross-reference package.json engines --- install.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/install.ps1 b/install.ps1 index 96c249192..7e5beeea3 100644 --- a/install.ps1 +++ b/install.ps1 @@ -34,7 +34,8 @@ 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) +# Preflight: check Node.js presence and version +# Must stay in sync with package.json "engines.node" (>= 18) $minimumNodeMajor = 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 $minimumNodeMajor or newer: https://nodejs.org")