Critical severity9.8GHSA Advisory· Published May 8, 2026· Updated May 8, 2026
CVE-2026-41501
CVE-2026-41501
Description
electerm is an open-sourced terminal/ssh/sftp/telnet/serialport/RDP/VNC/Spice/ftp client. Prior to version 3.3.8, a command injection vulnerability exists in github.com/elcterm/electerm/npm/install.js:130. The runLinux() function appends attacker-controlled remote version strings directly into an exec("rm -rf ...") command without validation. This issue has been patched in version 3.3.8.
Affected products
2Patches
159708b38c8a5Fix npm install.js security issue, support `npm i -g electerm` to deploy electerm binary and run electerm command in all OS (#4287)
10 files changed · +1384 −145
build/bin/prepublish.js+1 −2 modified@@ -1,7 +1,6 @@ const savedPackage = [ 'shelljs', - 'phin', - 'download' + 'tar' ] const pack = require('../../package.json') const fs = require('fs')
.github/workflows/npm.yml+1 −1 modified@@ -6,7 +6,7 @@ on: branches: [ test-npm ] jobs: release-npm: - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest environment: build if: ${{ !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip npm]') }} steps:
npm/electerm+93 −4 modified@@ -1,4 +1,93 @@ -#!/bin/bash -cd `dirname $0` -cd .. -./lib/node_modules/electerm/electerm/electerm \ No newline at end of file +#!/usr/bin/env node +/** + * electerm CLI launcher (cross-platform) + * After npm i -g electerm, the postinstall script downloads and installs the binary. + * This script simply finds and launches the installed binary. + * + * Binary locations: + * macOS: /Applications/electerm.app/Contents/MacOS/electerm + * Windows: <package>/electerm/electerm.exe + * Linux: <package>/electerm/electerm + */ + +const path = require('path') +const fs = require('fs') +const { spawn } = require('child_process') +const os = require('os') + +const plat = os.platform() +const packageRoot = path.resolve(__dirname, '..') + +function getElectermExePath () { + if (plat === 'darwin') { + const appBinary = '/Applications/electerm.app/Contents/MacOS/electerm' + if (fs.existsSync(appBinary)) { + return appBinary + } + return path.join(packageRoot, 'electerm', 'electerm') + } + + if (plat === 'win32') { + return path.join(packageRoot, 'electerm', 'electerm.exe') + } + + return path.join(packageRoot, 'electerm', 'electerm') +} + +function isSandboxReady () { + // chrome-sandbox must be owned by root (uid 0) and have setuid bit set (mode 4755) + // This requires root during install, which npm global install does not provide. + try { + const sandboxPath = path.join(packageRoot, 'electerm', 'chrome-sandbox') + if (!fs.existsSync(sandboxPath)) return false + const stat = fs.statSync(sandboxPath) + const hasSetuid = (stat.mode & 0o4000) !== 0 + const ownedByRoot = stat.uid === 0 + return hasSetuid && ownedByRoot + } catch (e) { + return false + } +} + +function launchElecterm () { + const exePath = getElectermExePath() + + if (!fs.existsSync(exePath)) { + console.error('electerm binary not found at:', exePath) + console.error('') + console.error('The binary may not have been installed properly.') + console.error('Try running manually:') + console.error(' node', path.join(packageRoot, 'npm', 'install.js')) + process.exit(1) + } + + const extraArgs = [] + if (plat === 'linux' && !isSandboxReady()) { + extraArgs.push('--no-sandbox') + } + + const child = spawn(exePath, [...extraArgs, ...process.argv.slice(2)], { + stdio: 'inherit', + detached: plat !== 'win32', + windowsHide: false + }) + + if (plat !== 'win32') { + child.unref() + } + + child.on('error', (err) => { + console.error('Failed to start electerm:', err.message) + process.exit(1) + }) + + child.on('exit', (code) => { + process.exit(code || 0) + }) +} + +if (require.main === module) { + launchElecterm() +} + +module.exports = { launchElecterm }
npm/install.js+409 −128 modified@@ -1,36 +1,70 @@ /** * install electerm from binary + * After npm i -g electerm, running `electerm` command will: + * 1. Download the appropriate binary for the platform + * 2. Extract it to the package directory (electerm/) + * 3. The bash script (npm/electerm) then launches the extracted binary + * + * This script only downloads and extracts. Launching is handled by the bash script. */ const os = require('os') -const { resolve } = require('path') -const { exec, rm, mv } = require('shelljs') -const rp = require('phin').promisified -const download = require('download') +const { resolve, join } = require('path') +const { execSync, rm, mv } = require('shelljs') +const { execFile } = require('child_process') +const fs = require('fs') +const { phin, download, extractTarGz, GITHUB_PROXY, applyProxy } = require('./utils') + const plat = os.platform() const arch = os.arch() const { homepage } = require('../package.json') + const releaseInfoUrl = `${homepage}/data/electerm-github-release.json?_=${+new Date()}` const versionUrl = `${homepage}/version.html?_=${+new Date()}` -function down (url, extract = true) { - const local = resolve(__dirname, '../') - console.log('downloading ' + url) - return download(url, local, { extract }).then(() => { - console.log('done!') - }) +// Directory where electerm package is installed +const packageRoot = resolve(__dirname, '..') +// Directory where the extracted binary will live +const extractDir = join(packageRoot, 'electerm') + +// --------------------------------------------------------------------------- +// Security helpers +// --------------------------------------------------------------------------- + +function sanitizeVersion (ver) { + const clean = String(ver).trim().replace(/^v/, '') + if (!/^\d+\.\d+\.\d+$/.test(clean)) { + throw new Error( + `Refusing to continue: remote version string failed validation: "${ver}"` + ) + } + return clean } +function sanitizeFilename (name) { + const clean = String(name).trim() + if (!/^[\w.-]+$/.test(clean)) { + throw new Error( + `Refusing to continue: remote filename failed validation: "${name}"` + ) + } + return clean +} + +// --------------------------------------------------------------------------- +// Core helpers +// --------------------------------------------------------------------------- + function getVer () { - return rp({ + return phin({ url: versionUrl, timeout: 15000 }) .then(res => res.body.toString()) } function getReleaseInfo (filter) { - return rp({ + return phin({ url: releaseInfoUrl, timeout: 15000 }) @@ -43,233 +77,480 @@ function getReleaseInfo (filter) { } function showFinalMessage () { - console.log('\n========================================') + console.log('') + console.log('========================================') console.log('electerm installation complete!') console.log('========================================') - console.log('\nFor more information, documentation, and updates, please visit:') - console.log('\x1b[36m%s\x1b[0m', 'https://electerm.html5beta.com') - console.log('\nThank you for using electerm!') - console.log('========================================\n') + console.log('') + console.log('For more information, documentation, and updates, please visit:') + console.log('https://electerm.html5beta.com') + console.log('') + console.log('Thank you for using electerm!') + console.log('========================================') + console.log('') } -// Check if running on Windows 7 or earlier +// --------------------------------------------------------------------------- +// Platform detection helpers +// --------------------------------------------------------------------------- + function isWindows7OrEarlier (platform, release) { if (platform !== 'win32') return false - // Windows 7 is NT 6.1, Windows 8 is NT 6.2, Windows 10 is NT 10.0 const [major, minor] = release.split('.').map(Number) return major < 10 && (major < 6 || (major === 6 && minor <= 1)) } -// Check if running on macOS 10.x (older than Big Sur 11.0) function isMacOS10 (platform, release) { if (platform !== 'darwin') return false - // Darwin kernel version: macOS 11 (Big Sur) = Darwin 20.x, macOS 10.15 = Darwin 19.x const majorVersion = parseInt(release.split('.')[0], 10) return majorVersion < 20 } -// Check if running on Linux with old glibc (< 2.34) -function isLinuxLegacy (platform, glibcVersion) { +function isLinuxLegacy (platform) { if (platform !== 'linux') return false - if (typeof glibcVersion === 'number') { - return glibcVersion < 2.34 - } try { - const result = exec('ldd --version 2>&1 | head -n1', { silent: true }) - if (result.code !== 0) return false - const output = result.stdout || '' - // Extract version number like "ldd (GNU libc) 2.31" or "ldd (Ubuntu GLIBC 2.35-0ubuntu3) 2.35" - const match = output.match(/(\d+\.\d+)\s*$/) + const result = execSync('ldd --version 2>&1 | head -n1', { encoding: 'utf8' }) + const match = result.match(/(\d+\.\d+)\s*$/) if (match) { - const version = parseFloat(match[1]) - return version < 2.34 + return parseFloat(match[1]) < 2.34 } return false } catch (e) { return false } } -// Get the file pattern for download based on platform/arch/legacy status -function getDownloadPattern (platform, architecture, options = {}) { - const { win7, mac10, linuxLegacy } = options +// --------------------------------------------------------------------------- +// Launch the extracted binary +// --------------------------------------------------------------------------- - if (platform === 'win32') { - if (win7) { - return { pattern: /electerm-\d+\.\d+\.\d+-win7\.tar\.gz$/, type: 'win7' } - } else if (architecture === 'arm64') { - return { pattern: /electerm-\d+\.\d+\.\d+-win-arm64\.tar\.gz$/, type: 'win-arm64' } - } else { - return { pattern: /electerm-\d+\.\d+\.\d+-win-x64\.tar\.gz$/, type: 'win-x64' } - } - } else if (platform === 'darwin') { - if (mac10) { - return { pattern: /mac10-x64\.dmg$/, type: 'mac10-x64' } - } else if (architecture === 'arm64') { - return { pattern: /mac-arm64\.dmg$/, type: 'mac-arm64' } - } else { - return { pattern: /mac-x64\.dmg$/, type: 'mac-x64' } - } - } else if (platform === 'linux') { - const suffix = linuxLegacy ? '-legacy' : '' - if (architecture === 'arm64') { - return { pattern: new RegExp(`linux-arm64${suffix}\\.tar\\.gz$`), type: `linux-arm64${suffix}` } - } else if (architecture === 'arm') { - return { pattern: new RegExp(`linux-armv7l${suffix}\\.tar\\.gz$`), type: `linux-armv7l${suffix}` } - } else { - return { pattern: new RegExp(`linux-x64${suffix}\\.tar\\.gz$`), type: `linux-x64${suffix}` } - } +/** + * Get the path to the extracted electerm executable + */ +function getElectermExePath () { + if (plat === 'win32') { + return join(extractDir, 'electerm.exe') } - return { pattern: null, type: 'unsupported' } + // Linux and macOS (if extracted) + return join(extractDir, 'electerm') } +/** + * Check if the electerm binary has been extracted already + */ +function isElectermExtracted () { + const exePath = getElectermExePath() + return fs.existsSync(exePath) +} + +// --------------------------------------------------------------------------- +// Platform installers +// --------------------------------------------------------------------------- + async function runLinux (folderName, filePattern) { - const ver = await getVer() - const target = resolve(__dirname, `../electerm-${ver.replace('v', '')}-${folderName}`) - const targetNew = resolve(__dirname, '../electerm') - exec(`rm -rf ${target} ${targetNew}`) + const rawVer = await getVer() + const ver = sanitizeVersion(rawVer) + + console.log(` Version: ${ver}`) + console.log(` Target: ${folderName}`) + + const target = join(packageRoot, `electerm-${ver}-${folderName}`) + + // Clean up old installations + rm('-rf', [target, extractDir]) + + console.log(' Fetching release info...') const releaseInfo = await getReleaseInfo(r => r.name.includes(filePattern)) if (!releaseInfo) { throw new Error(`No release found for pattern: ${filePattern}`) } - await down(releaseInfo.browser_download_url) - exec(`mv ${target} ${targetNew}`) + + // Download without extracting to packageRoot directly + // We'll extract to a temp location first + const tmpDir = join(packageRoot, '.electerm-tmp') + rm('-rf', tmpDir) + fs.mkdirSync(tmpDir, { recursive: true }) + + const proxyUrl = applyProxy(releaseInfo.browser_download_url) + console.log(` URL: ${proxyUrl}`) + + const { filepath } = await download(releaseInfo.browser_download_url, tmpDir, { extract: false, displayName: releaseInfo.name }) + + // Extract to tmpDir (keeps top-level folder name) + await extractTarGz(filepath, tmpDir) + + // Find the extracted folder (should be the only directory) + const entries = fs.readdirSync(tmpDir) + const extractedFolder = entries.find(e => fs.statSync(join(tmpDir, e)).isDirectory()) + + if (!extractedFolder) { + throw new Error('No folder found in extracted archive') + } + + // Move to extractDir + console.log(` Installing to: ${extractDir}`) + mv(join(tmpDir, extractedFolder), extractDir) + + // Fix chrome-sandbox permissions on Linux (Electron requires specific permissions) + // Note: setting the setuid bit requires root ownership, which npm install cannot provide. + // The launcher handles this by passing --no-sandbox when the sandbox is not root-owned. + if (plat === 'linux') { + const chromeSandboxPath = join(extractDir, 'chrome-sandbox') + if (fs.existsSync(chromeSandboxPath)) { + console.log(' Note: To enable the Electron sandbox, run:') + console.log(` sudo chown root:root "${chromeSandboxPath}"`) + console.log(` sudo chmod 4755 "${chromeSandboxPath}"`) + console.log(' Otherwise, electerm will launch with --no-sandbox automatically.') + } + } + + // Clean up temp files + rm('-rf', tmpDir) + showFinalMessage() - exec('electerm') } -async function runMac (archName) { - const pattern = new RegExp(`mac-${archName}\\.dmg$`) +async function runWin (archName) { + const rawVer = await getVer() + const ver = sanitizeVersion(rawVer) + + console.log(` Version: ${ver}`) + console.log(` Target: win-${archName}`) + + const target = join(packageRoot, `electerm-${ver}-win-${archName}`) + + rm('-rf', [target, extractDir]) + fs.mkdirSync(extractDir, { recursive: true }) + + const pattern = new RegExp(`electerm-\\d+\\.\\d+\\.\\d+-win-${archName}\\.tar\\.gz$`) + console.log(' Fetching release info...') const releaseInfo = await getReleaseInfo(r => pattern.test(r.name)) if (!releaseInfo) { - throw new Error(`No release found for Mac ${archName}`) + throw new Error(`No release found for Windows ${archName}`) + } + + // Download to a temp file, then extract directly to extractDir with strip:1 + // (avoids a rename which can fail on Windows when AV has file locks) + const tmpDir = join(packageRoot, '.electerm-tmp') + rm('-rf', tmpDir) + fs.mkdirSync(tmpDir, { recursive: true }) + + const proxyUrl = applyProxy(releaseInfo.browser_download_url) + console.log(` URL: ${proxyUrl}`) + + const { filepath } = await download(releaseInfo.browser_download_url, tmpDir, { extract: false, displayName: releaseInfo.name }) + + console.log(` Installing to: ${extractDir}`) + await extractTarGz(filepath, extractDir, 1) + + rm('-rf', tmpDir) + + const exePath = getElectermExePath() + if (!fs.existsSync(exePath)) { + throw new Error(`electerm.exe not found at ${exePath} after extraction. Archive may have an unexpected structure.`) } - await down(releaseInfo.browser_download_url, false) - const target = resolve(__dirname, '../', releaseInfo.name) + showFinalMessage() - exec(`open ${target}`) } -// macOS 10.x specific version -async function runMac10 () { - const releaseInfo = await getReleaseInfo(r => /mac10-x64\.dmg$/.test(r.name)) +async function runWin7 () { + const rawVer = await getVer() + const ver = sanitizeVersion(rawVer) + + console.log(` Version: ${ver}`) + console.log(' Target: win7') + + const target = join(packageRoot, `electerm-${ver}-win7`) + + rm('-rf', [target, extractDir]) + fs.mkdirSync(extractDir, { recursive: true }) + + console.log(' Fetching release info...') + const releaseInfo = await getReleaseInfo(r => /electerm-\d+\.\d+\.\d+-win7\.tar\.gz$/.test(r.name)) if (!releaseInfo) { - throw new Error('No release found for macOS 10.x') + throw new Error('No release found for Windows 7') + } + + const tmpDir = join(packageRoot, '.electerm-tmp') + rm('-rf', tmpDir) + fs.mkdirSync(tmpDir, { recursive: true }) + + const proxyUrl = applyProxy(releaseInfo.browser_download_url) + console.log(` URL: ${proxyUrl}`) + + const { filepath } = await download(releaseInfo.browser_download_url, tmpDir, { extract: false, displayName: releaseInfo.name }) + + console.log(` Installing to: ${extractDir}`) + await extractTarGz(filepath, extractDir, 1) + + rm('-rf', tmpDir) + + const exePath = getElectermExePath() + if (!fs.existsSync(exePath)) { + throw new Error(`electerm.exe not found at ${exePath} after extraction. Archive may have an unexpected structure.`) } - await down(releaseInfo.browser_download_url, false) - const target = resolve(__dirname, '../', releaseInfo.name) + showFinalMessage() - exec(`open ${target}`) } -async function runWin (archName) { - const ver = await getVer() - const target = resolve(__dirname, `../electerm-${ver.replace('v', '')}-win-${archName}`) - const targetNew = resolve(__dirname, '../electerm') - rm('-rf', [ - target, - targetNew - ]) - const pattern = new RegExp(`electerm-\\d+\\.\\d+\\.\\d+-win-${archName}\\.tar\\.gz$`) +/** + * Mount a DMG, copy the .app to /Applications, then detach + * @param {string} dmgPath - Path to the DMG file + * @returns {Promise<string>} - Path to the installed app + */ +function installFromDmg (dmgPath) { + return new Promise((resolve, reject) => { + // Step 1: Mount the DMG + console.log(' Mounting DMG...') + execFile('hdiutil', ['attach', dmgPath, '-nobrowse', '-readonly'], (err, stdout) => { + if (err) { + reject(new Error(`Failed to mount DMG: ${err.message}`)) + return + } + + // Parse mount point + const mountMatch = stdout.match(/(\/Volumes\/[^\n]+)/) + if (!mountMatch) { + reject(new Error('Could not find mount point')) + return + } + + const mountPoint = mountMatch[1].trim() + console.log(` Mounted at: ${mountPoint}`) + + // Step 2: Find the .app bundle + try { + const entries = fs.readdirSync(mountPoint) + const appFile = entries.find(e => e.endsWith('.app')) + + if (!appFile) { + // Try to detach before rejecting + execFileSyncIgnore('hdiutil', ['detach', mountPoint]) + reject(new Error('No .app bundle found in DMG')) + return + } + + const appSource = join(mountPoint, appFile) + const appDest = `/Applications/${appFile}` + + // Check if app already exists + if (fs.existsSync(appDest)) { + console.log(` Existing app found at ${appDest}, replacing...`) + // Remove existing app + rm('-rf', appDest) + } + + // Step 3: Copy the app to /Applications + console.log(` Installing ${appFile} to /Applications...`) + execFile('cp', ['-R', appSource, appDest], (cpErr) => { + // Step 4: Detach the DMG (always, regardless of copy result) + console.log(' Detaching DMG...') + execFile('hdiutil', ['detach', mountPoint], (detachErr) => { + if (detachErr) { + console.log(' Warning: Failed to detach DMG:', detachErr.message) + } else { + console.log(' DMG detached') + } + + if (cpErr) { + reject(new Error(`Failed to copy app: ${cpErr.message}`)) + return + } + + console.log(` App installed to: ${appDest}`) + resolve(appDest) + }) + }) + } catch (e) { + // Try to detach before rejecting + execFileSyncIgnore('hdiutil', ['detach', mountPoint]) + reject(e) + } + }) + }) +} + +/** + * Execute a file synchronously, ignoring errors + */ +function execFileSyncIgnore (cmd, args) { + try { + execSync(cmd, args, { stdio: 'ignore' }) + } catch (e) { + // Ignore + } +} + +async function runMac (archName) { + const pattern = new RegExp(`mac-${archName}\\.dmg$`) + console.log(' Fetching release info...') const releaseInfo = await getReleaseInfo(r => pattern.test(r.name)) if (!releaseInfo) { - throw new Error(`No release found for Windows ${archName}`) + throw new Error(`No release found for Mac ${archName}`) } - await down(releaseInfo.browser_download_url) - await mv(target, targetNew) + + const safeName = sanitizeFilename(releaseInfo.name) + const proxyUrl = applyProxy(releaseInfo.browser_download_url) + console.log(` URL: ${proxyUrl}`) + + await download(releaseInfo.browser_download_url, packageRoot, { extract: false, displayName: releaseInfo.name }) + + const dmgPath = join(packageRoot, safeName) showFinalMessage() - require('child_process').execFile(`${targetNew}\\electerm.exe`) + + // Install from DMG automatically + try { + await installFromDmg(dmgPath) + + // Clean up DMG + try { + fs.unlinkSync(dmgPath) + console.log(' Cleaned up DMG file') + } catch (e) { + // Ignore cleanup errors + } + + console.log('') + console.log(' Installation complete! You can now launch electerm from /Applications') + } catch (err) { + console.error('') + console.error(' Warning: Automatic installation failed:', err.message) + console.error(' Please manually copy the app from the DMG to /Applications') + console.error('') + console.log(' Opening DMG for manual installation...') + execFile('open', [dmgPath]) + } } -// Windows 7 specific version -async function runWin7 () { - const ver = await getVer() - const target = resolve(__dirname, `../electerm-${ver.replace('v', '')}-win7`) - const targetNew = resolve(__dirname, '../electerm') - rm('-rf', [ - target, - targetNew - ]) - const releaseInfo = await getReleaseInfo(r => /electerm-\d+\.\d+\.\d+-win7\.tar\.gz$/.test(r.name)) +async function runMac10 () { + console.log(' Fetching release info...') + const releaseInfo = await getReleaseInfo(r => /mac10-x64\.dmg$/.test(r.name)) if (!releaseInfo) { - throw new Error('No release found for Windows 7') + throw new Error('No release found for macOS 10.x') } - await down(releaseInfo.browser_download_url) - await mv(target, targetNew) + + const safeName = sanitizeFilename(releaseInfo.name) + const proxyUrl = applyProxy(releaseInfo.browser_download_url) + console.log(` URL: ${proxyUrl}`) + + await download(releaseInfo.browser_download_url, packageRoot, { extract: false, displayName: releaseInfo.name }) + + const dmgPath = join(packageRoot, safeName) showFinalMessage() - require('child_process').execFile(`${targetNew}\\electerm.exe`) + + // Install from DMG automatically + try { + await installFromDmg(dmgPath) + + // Clean up DMG + try { + fs.unlinkSync(dmgPath) + console.log(' Cleaned up DMG file') + } catch (e) { + // Ignore cleanup errors + } + + console.log('') + console.log(' Installation complete! You can now launch electerm from /Applications') + } catch (err) { + console.error('') + console.error(' Warning: Automatic installation failed:', err.message) + console.error(' Please manually copy the app from the DMG to /Applications') + console.error('') + console.log(' Opening DMG for manual installation...') + execFile('open', [dmgPath]) + } } +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + async function main () { - console.log(`Detected platform: ${plat}, architecture: ${arch}`) + console.log('') + console.log('========================================') + console.log('electerm binary installer') + console.log('========================================') + console.log(`Platform: ${plat}, Architecture: ${arch}`) + + if (GITHUB_PROXY) { + console.log(`GitHub Proxy: ${GITHUB_PROXY}`) + } + + console.log('') // Check for legacy systems const win7 = isWindows7OrEarlier(plat, os.release()) const mac10 = isMacOS10(plat, os.release()) const linuxLegacy = isLinuxLegacy(plat) - if (win7) console.log('Detected: Windows 7 or earlier') - if (mac10) console.log('Detected: macOS 10.x') - if (linuxLegacy) console.log('Detected: Linux with glibc < 2.34 (legacy)') + if (win7) console.log(' Detected: Windows 7 or earlier') + if (mac10) console.log(' Detected: macOS 10.x') + if (linuxLegacy) console.log(' Detected: Linux with glibc < 2.34 (legacy)') - console.log('Fetching release information...\n') + console.log(' Fetching release information...') try { if (plat === 'win32') { - // Windows: x64, arm64, win7 if (win7) { await runWin7() } else if (arch === 'arm64') { await runWin('arm64') } else { - // Default to x64 for all other Windows architectures await runWin('x64') } } else if (plat === 'darwin') { - // macOS: x64, arm64, mac10 if (mac10) { await runMac10() } else if (arch === 'arm64') { await runMac('arm64') } else { - // Default to x64 for Intel Macs await runMac('x64') } } else if (plat === 'linux') { - // Linux: x64, arm64, armv7l (with legacy variants) const suffix = linuxLegacy ? '-legacy' : '' - if (arch === 'arm64') { + if (arch === 'arm64' || arch === 'aarch64') { await runLinux(`linux-arm64${suffix}`, `linux-arm64${suffix}.tar.gz`) } else if (arch === 'arm') { await runLinux(`linux-armv7l${suffix}`, `linux-armv7l${suffix}.tar.gz`) } else { - // Default to x64 for all other Linux architectures await runLinux(`linux-x64${suffix}`, `linux-x64${suffix}.tar.gz`) } } else { throw new Error(`Platform "${plat}" is not supported.`) } } catch (err) { - console.error('\n========================================') + console.error('') + console.error('========================================') console.error('Installation failed!') console.error('========================================') console.error(`Error: ${err.message}`) - console.error(`\nPlatform: ${plat}, Architecture: ${arch}`) - console.error('\nPlease visit https://electerm.html5beta.com for manual download options.') - console.error('========================================\n') + console.error(`Platform: ${plat}, Architecture: ${arch}`) + console.error('') + console.error('Please visit https://electerm.html5beta.com for manual download options.') + console.error('========================================') + console.error('') process.exit(1) } } -// Export functions for testing +// --------------------------------------------------------------------------- +// Exports for testing +// --------------------------------------------------------------------------- + module.exports = { isWindows7OrEarlier, isMacOS10, isLinuxLegacy, - getDownloadPattern + sanitizeVersion, + sanitizeFilename, + getElectermExePath, + isElectermExtracted, + // Expose for test injection + _packageRoot: packageRoot, + _extractDir: extractDir } -// Run main only if this file is executed directly if (require.main === module) { main() }
npm/utils.js+263 −0 added@@ -0,0 +1,263 @@ +/** + * Utility functions for npm installer + * Replaces download and phin packages with native Node.js http/https and tar + * Supports Node.js 16+ + * Supports GITHUB_PROXY environment variable for proxying GitHub URLs + */ + +const https = require('https') +const http = require('http') +const fs = require('fs') +const path = require('path') +const tar = require('tar') + +// GitHub proxy support +const GITHUB_PROXY = process.env.GITHUB_PROXY || '' + +/** + * Apply GitHub proxy to URLs if configured + * @param {string} url - Original URL + * @returns {string} - Proxy URL or original URL + */ +function applyProxy (url) { + if (!GITHUB_PROXY) return url + if (!url.includes('github.com')) return url + + // Remove trailing slash from proxy if present + const proxy = GITHUB_PROXY.replace(/\/+$/, '') + // Ensure url has protocol + const urlWithProto = url.startsWith('http') ? url : `https://${url}` + + return `${proxy}/${urlWithProto}` +} + +/** + * Format bytes to human readable + */ +function formatBytes (bytes) { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] +} + +/** + * Make an HTTP GET request and download to a file with progress + * @param {string} url - URL to fetch + * @param {string} filepath - Destination file path + * @param {number} timeout - Request timeout in milliseconds (default: 300000 = 5min) + * @param {function} onProgress - Progress callback (received, total, percent) + * @returns {Promise<string>} Path to downloaded file + */ +function httpDownload (url, filepath, timeout = 300000, onProgress) { + return new Promise((resolve, reject) => { + const client = url.startsWith('https') ? https : http + + const req = client.get(url, { timeout }, (res) => { + // Handle redirects + if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307) { + if (res.headers.location) { + // Handle relative URLs + let redirectUrl = res.headers.location + if (!redirectUrl.startsWith('http://') && !redirectUrl.startsWith('https://')) { + const parsedUrl = new URL(url) + redirectUrl = `${parsedUrl.protocol}//${parsedUrl.host}${redirectUrl}` + } + resolve(httpDownload(redirectUrl, filepath, timeout, onProgress)) + return + } + } + + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage || 'Unknown error'}`)) + return + } + + const total = parseInt(res.headers['content-length'] || '0', 10) + let received = 0 + let lastPercent = -1 + + const fileStream = fs.createWriteStream(filepath) + + res.on('data', (chunk) => { + received += chunk.length + if (onProgress && total > 0) { + const percent = Math.round((received / total) * 100) + if (percent !== lastPercent) { + lastPercent = percent + onProgress(received, total, percent) + } + } + }) + + res.pipe(fileStream) + fileStream.on('finish', () => { + fileStream.close() + resolve(filepath) + }) + fileStream.on('error', (err) => { + fs.unlink(filepath, () => {}) // Clean up partial download + reject(err) + }) + }) + + req.on('error', reject) + req.on('timeout', () => { + req.destroy() + reject(new Error(`Request timeout after ${timeout}ms`)) + }) + }) +} + +/** + * Make an HTTP GET request and return response body as string + * @param {string} url - URL to fetch + * @param {number} timeout - Request timeout in milliseconds (default: 15000) + * @returns {Promise<string>} Response body as string + */ +function httpGet (url, timeout = 15000) { + return new Promise((resolve, reject) => { + const client = url.startsWith('https') ? https : http + + const req = client.get(url, { timeout }, (res) => { + // Handle redirects + if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307) { + if (res.headers.location) { + // Handle relative URLs + let redirectUrl = res.headers.location + if (!redirectUrl.startsWith('http://') && !redirectUrl.startsWith('https://')) { + const parsedUrl = new URL(url) + redirectUrl = `${parsedUrl.protocol}//${parsedUrl.host}${redirectUrl}` + } + resolve(httpGet(redirectUrl, timeout)) + return + } + } + + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage || 'Unknown error'}`)) + return + } + + const chunks = [] + res.on('data', (chunk) => chunks.push(chunk)) + res.on('end', () => { + const buffer = Buffer.concat(chunks) + resolve(buffer.toString()) + }) + res.on('error', reject) + }) + + req.on('error', reject) + req.on('timeout', () => { + req.destroy() + reject(new Error(`Request timeout after ${timeout}ms`)) + }) + }) +} + +/** + * Extract a tar.gz file to destination directory + * @param {string} filepath - Path to tar.gz file + * @param {string} dest - Destination directory + * @param {number} strip - Number of leading path components to strip (default: 0) + * @returns {Promise<void>} + */ +function extractTarGz (filepath, dest, strip = 0) { + return tar.extract({ + file: filepath, + cwd: dest, + strip + }) +} + +/** + * Download and optionally extract a file with progress + * @param {string} url - URL to download from + * @param {string} dest - Destination directory + * @param {object} options - Options + * @param {boolean} options.extract - Whether to extract the file (default: true) + * @param {string} options.displayName - Display name for progress output + * @returns {Promise<{filepath: string, extracted: boolean}>} + */ +async function download (url, dest, { extract: doExtract = true, displayName } = {}) { + // Ensure dest directory exists + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }) + } + + // Extract filename from URL + const urlParts = url.split('/') + const filename = urlParts[urlParts.length - 1].split('?')[0] || 'download' + const filepath = path.join(dest, filename) + + // Apply proxy if configured + const downloadUrl = applyProxy(url) + + const label = displayName || filename + const proxyInfo = GITHUB_PROXY ? ' [via proxy]' : '' + + console.log('') + console.log(` Downloading: ${label}${proxyInfo}`) + + let lastPercent = -1 + await httpDownload(downloadUrl, filepath, 300000, (received, total, percent) => { + if (percent !== lastPercent && percent % 10 === 0) { + lastPercent = percent + const receivedStr = formatBytes(received) + const totalStr = formatBytes(total) + process.stdout.write(` Progress: ${percent}% (${receivedStr} / ${totalStr})\n`) + } + }) + + console.log(` Progress: 100% (${formatBytes(fs.statSync(filepath).size)})`) + console.log(' Download complete!') + + let extracted = false + if (doExtract && (filepath.endsWith('.tar.gz') || filepath.endsWith('.tgz'))) { + console.log(' Extracting archive...') + await extractTarGz(filepath, dest) + // Clean up the downloaded archive + try { + fs.unlinkSync(filepath) + } catch (err) { + // Ignore cleanup errors + } + extracted = true + console.log(' Extraction complete!') + } + + return { filepath, extracted } +} + +/** + * Phin replacement - simple promisified HTTP client + * @param {object} options - Request options + * @param {string} options.url - URL to fetch + * @param {number} options.timeout - Request timeout (default: 15000) + * @returns {Promise<{body: Buffer, statusCode: number, headers: object}>} + */ +async function phin (options) { + const { url, timeout = 15000 } = options + const body = await httpGet(url, timeout) + + return { + body: Buffer.from(body), + statusCode: 200, + headers: {} + } +} + +phin.promisified = phin + +module.exports = { + httpGet, + httpDownload, + extractTarGz, + download, + phin, + applyProxy, + formatBytes, + GITHUB_PROXY +}
package.json+1 −1 modified@@ -1,6 +1,6 @@ { "name": "electerm", - "version": "3.3.0", + "version": "3.3.5", "description": "Terminal/ssh/telnet/serialport/sftp client(linux, mac, win)", "main": "app.js", "bin": "npm/electerm",
package-lock.json+2 −2 modified@@ -1,12 +1,12 @@ { "name": "electerm", - "version": "3.3.0", + "version": "3.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "electerm", - "version": "3.3.0", + "version": "3.3.5", "hasInstallScript": true, "license": "MIT", "dependencies": {
README_cn.md+0 −4 modified@@ -89,10 +89,6 @@ scoop install dorado/electerm ```bash npm i -g electerm - -# after installation, it will immediately open for windows and linux, -# for macOS, it will open the drag to install panel - ``` ## 升级
README.md+0 −3 modified@@ -90,9 +90,6 @@ Check [https://electerm-repos.html5beta.com/deb](https://electerm-repos.html5bet ```bash npm i -g electerm -# After installation, it will immediately open for windows and linux, -# For macOS, it will open the drag to install panel - ``` ## Upgrade
test/unit/npm-install2.spec.js+614 −0 added@@ -0,0 +1,614 @@ +/** + * Tests for npm/install.js and npm/utils.js + * Tests platform detection, download patterns, extraction, and CLI launcher flow + */ + +const os = require('os') +const path = require('path') +const fs = require('fs') +const tar = require('tar') +const { + isWindows7OrEarlier, + isMacOS10, + isLinuxLegacy, + sanitizeVersion, + sanitizeFilename, + getElectermExePath, + isElectermExtracted, + _packageRoot, + _extractDir +} = require('../../npm/install') + +const { + httpGet, + extractTarGz, + download, + phin, + applyProxy, + formatBytes +} = require('../../npm/utils') + +const plat = os.platform() + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +let passed = 0 +let failed = 0 +const asyncQueue = [] + +function test (name, fn) { + try { + fn() + console.log(` ✓ ${name}`) + passed++ + } catch (e) { + console.error(` ✗ ${name}`) + console.error(` Error: ${e.message}`) + failed++ + } +} + +function testAsync (name, fn) { + asyncQueue.push(async () => { + try { + await fn() + console.log(` ✓ ${name}`) + passed++ + } catch (e) { + console.error(` ✗ ${name}`) + console.error(` Error: ${e.message}`) + failed++ + } + }) +} + +function expect (actual) { + return { + toBe (expected) { + if (actual !== expected) { + throw new Error(`Expected ${expected} but got ${actual}`) + } + }, + toEqual (expected) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`) + } + }, + toContain (expected) { + if (!actual.includes(expected)) { + throw new Error(`Expected ${actual} to contain ${expected}`) + } + }, + toMatch (pattern) { + if (!pattern.test(actual)) { + throw new Error(`Expected ${actual} to match ${pattern}`) + } + }, + toBeTruthy () { + if (!actual) { + throw new Error(`Expected ${actual} to be truthy`) + } + }, + toBeFalsy () { + if (actual) { + throw new Error(`Expected ${actual} to be falsy`) + } + } + } +} + +function testThrows (fn, expectedMessage) { + try { + fn() + throw new Error('Expected function to throw') + } catch (e) { + if (e.message === 'Expected function to throw') throw e + if (expectedMessage && !e.message.includes(expectedMessage)) { + throw new Error(`Expected error to include "${expectedMessage}" but got "${e.message}"`) + } + } +} + +// --------------------------------------------------------------------------- +// Download pattern helper (for testing) +// --------------------------------------------------------------------------- + +function getDownloadPattern (platform, architecture, options = {}) { + const { win7, mac10, linuxLegacy } = options + + if (platform === 'win32') { + if (win7) { + return { pattern: /electerm-\d+\.\d+\.\d+-win7\.tar\.gz$/, type: 'win7' } + } else if (architecture === 'arm64') { + return { pattern: /electerm-\d+\.\d+\.\d+-win-arm64\.tar\.gz$/, type: 'win-arm64' } + } else { + return { pattern: /electerm-\d+\.\d+\.\d+-win-x64\.tar\.gz$/, type: 'win-x64' } + } + } else if (platform === 'darwin') { + if (mac10) { + return { pattern: /mac10-x64\.dmg$/, type: 'mac10-x64' } + } else if (architecture === 'arm64') { + return { pattern: /mac-arm64\.dmg$/, type: 'mac-arm64' } + } else { + return { pattern: /mac-x64\.dmg$/, type: 'mac-x64' } + } + } else if (platform === 'linux') { + const suffix = linuxLegacy ? '-legacy' : '' + if (architecture === 'arm64' || architecture === 'aarch64') { + return { pattern: new RegExp(`linux-arm64${suffix}\\.tar\\.gz$`), type: `linux-arm64${suffix}` } + } else if (architecture === 'arm') { + return { pattern: new RegExp(`linux-armv7l${suffix}\\.tar\\.gz$`), type: `linux-armv7l${suffix}` } + } else { + return { pattern: new RegExp(`linux-x64${suffix}\\.tar\\.gz$`), type: `linux-x64${suffix}` } + } + } + + return { pattern: null, type: 'unsupported' } +} + +// ============================================================================= +// Tests: Platform Detection +// ============================================================================= + +console.log('\n=== Platform Detection Tests ===\n') + +test('isWindows7OrEarlier: returns false for non-Windows platforms', () => { + expect(isWindows7OrEarlier('darwin', '21.0.0')).toBe(false) + expect(isWindows7OrEarlier('linux', '5.4.0')).toBe(false) +}) + +test('isWindows7OrEarlier: returns true for Windows 7 (NT 6.1)', () => { + expect(isWindows7OrEarlier('win32', '6.1.7601')).toBe(true) +}) + +test('isWindows7OrEarlier: returns true for Windows Vista (NT 6.0)', () => { + expect(isWindows7OrEarlier('win32', '6.0.6000')).toBe(true) +}) + +test('isWindows7OrEarlier: returns true for Windows XP (NT 5.1)', () => { + expect(isWindows7OrEarlier('win32', '5.1.2600')).toBe(true) +}) + +test('isWindows7OrEarlier: returns false for Windows 8 (NT 6.2)', () => { + expect(isWindows7OrEarlier('win32', '6.2.9200')).toBe(false) +}) + +test('isWindows7OrEarlier: returns false for Windows 10 (NT 10.0)', () => { + expect(isWindows7OrEarlier('win32', '10.0.19041')).toBe(false) +}) + +test('isWindows7OrEarlier: returns false for Windows 11 (NT 10.0)', () => { + expect(isWindows7OrEarlier('win32', '10.0.22000')).toBe(false) +}) + +test('isMacOS10: returns false for non-macOS platforms', () => { + expect(isMacOS10('win32', '10.0.19041')).toBe(false) + expect(isMacOS10('linux', '5.4.0')).toBe(false) +}) + +test('isMacOS10: returns true for macOS 10.15 Catalina (Darwin 19.x)', () => { + expect(isMacOS10('darwin', '19.6.0')).toBe(true) +}) + +test('isMacOS10: returns true for macOS 10.14 Mojave (Darwin 18.x)', () => { + expect(isMacOS10('darwin', '18.7.0')).toBe(true) +}) + +test('isMacOS10: returns false for macOS 11 Big Sur (Darwin 20.x)', () => { + expect(isMacOS10('darwin', '20.6.0')).toBe(false) +}) + +test('isMacOS10: returns false for macOS 14 Sonoma (Darwin 23.x)', () => { + expect(isMacOS10('darwin', '23.0.0')).toBe(false) +}) + +test('isLinuxLegacy: returns false for non-Linux platforms', () => { + expect(isLinuxLegacy('win32')).toBe(false) + expect(isLinuxLegacy('darwin')).toBe(false) +}) + +// ============================================================================= +// Tests: Security Sanitization +// ============================================================================= + +console.log('\n=== Security Sanitization Tests ===\n') + +test('sanitizeVersion: removes v prefix', () => { + expect(sanitizeVersion('v1.2.3')).toBe('1.2.3') +}) + +test('sanitizeVersion: trims whitespace', () => { + expect(sanitizeVersion(' 1.2.3 ')).toBe('1.2.3') +}) + +test('sanitizeVersion: passes valid semver', () => { + expect(sanitizeVersion('1.2.3')).toBe('1.2.3') + expect(sanitizeVersion('3.2.0')).toBe('3.2.0') +}) + +test('sanitizeVersion: throws on invalid version', () => { + testThrows(() => sanitizeVersion('1.2'), 'validation') + testThrows(() => sanitizeVersion('abc'), 'validation') + testThrows(() => sanitizeVersion('1.2.3.4'), 'validation') + testThrows(() => sanitizeVersion('1.2.3-beta'), 'validation') +}) + +test('sanitizeFilename: passes valid filenames', () => { + expect(sanitizeFilename('electerm-3.2.0-linux-x64.tar.gz')).toBe('electerm-3.2.0-linux-x64.tar.gz') + expect(sanitizeFilename('electerm-3.2.0-mac-x64.dmg')).toBe('electerm-3.2.0-mac-x64.dmg') +}) + +test('sanitizeFilename: trims whitespace', () => { + expect(sanitizeFilename(' test.tar.gz ')).toBe('test.tar.gz') +}) + +test('sanitizeFilename: throws on invalid filenames', () => { + testThrows(() => sanitizeFilename('../evil.sh'), 'validation') + testThrows(() => sanitizeFilename('test; rm -rf /'), 'validation') + testThrows(() => sanitizeFilename('test$(whoami).tar.gz'), 'validation') +}) + +// ============================================================================= +// Tests: Download Patterns +// ============================================================================= + +console.log('\n=== Download Pattern Tests ===\n') + +const v = '3.2.0' +const releaseFiles = [ + `electerm-${v}-linux-arm64.tar.gz`, + `electerm-${v}-linux-arm64-legacy.tar.gz`, + `electerm-${v}-linux-armv7l.tar.gz`, + `electerm-${v}-linux-armv7l-legacy.tar.gz`, + `electerm-${v}-linux-x64.tar.gz`, + `electerm-${v}-linux-x64-legacy.tar.gz`, + `electerm-${v}-mac-arm64.dmg`, + `electerm-${v}-mac-x64.dmg`, + `electerm-${v}-mac10-x64.dmg`, + `electerm-${v}-win-arm64.tar.gz`, + `electerm-${v}-win-x64.tar.gz`, + `electerm-${v}-win7.tar.gz` +] + +test('pattern: win-x64 matches exactly one file', () => { + const { pattern } = getDownloadPattern('win32', 'x64', {}) + const matches = releaseFiles.filter(f => pattern.test(f)) + expect(matches).toEqual([`electerm-${v}-win-x64.tar.gz`]) +}) + +test('pattern: win-arm64 matches exactly one file', () => { + const { pattern } = getDownloadPattern('win32', 'arm64', {}) + const matches = releaseFiles.filter(f => pattern.test(f)) + expect(matches).toEqual([`electerm-${v}-win-arm64.tar.gz`]) +}) + +test('pattern: win7 matches exactly one file', () => { + const { pattern } = getDownloadPattern('win32', 'x64', { win7: true }) + const matches = releaseFiles.filter(f => pattern.test(f)) + expect(matches).toEqual([`electerm-${v}-win7.tar.gz`]) +}) + +test('pattern: mac-x64 matches exactly one file', () => { + const { pattern } = getDownloadPattern('darwin', 'x64', {}) + const matches = releaseFiles.filter(f => pattern.test(f)) + expect(matches).toEqual([`electerm-${v}-mac-x64.dmg`]) +}) + +test('pattern: mac-arm64 matches exactly one file', () => { + const { pattern } = getDownloadPattern('darwin', 'arm64', {}) + const matches = releaseFiles.filter(f => pattern.test(f)) + expect(matches).toEqual([`electerm-${v}-mac-arm64.dmg`]) +}) + +test('pattern: mac10-x64 matches exactly one file', () => { + const { pattern } = getDownloadPattern('darwin', 'x64', { mac10: true }) + const matches = releaseFiles.filter(f => pattern.test(f)) + expect(matches).toEqual([`electerm-${v}-mac10-x64.dmg`]) +}) + +test('pattern: linux-x64 matches exactly one file', () => { + const { pattern } = getDownloadPattern('linux', 'x64', {}) + const matches = releaseFiles.filter(f => pattern.test(f)) + expect(matches).toEqual([`electerm-${v}-linux-x64.tar.gz`]) +}) + +test('pattern: linux-x64-legacy matches exactly one file', () => { + const { pattern } = getDownloadPattern('linux', 'x64', { linuxLegacy: true }) + const matches = releaseFiles.filter(f => pattern.test(f)) + expect(matches).toEqual([`electerm-${v}-linux-x64-legacy.tar.gz`]) +}) + +test('pattern: linux-arm64 matches exactly one file', () => { + const { pattern } = getDownloadPattern('linux', 'arm64', {}) + const matches = releaseFiles.filter(f => pattern.test(f)) + expect(matches).toEqual([`electerm-${v}-linux-arm64.tar.gz`]) +}) + +test('pattern: linux-arm64-legacy matches exactly one file', () => { + const { pattern } = getDownloadPattern('linux', 'arm64', { linuxLegacy: true }) + const matches = releaseFiles.filter(f => pattern.test(f)) + expect(matches).toEqual([`electerm-${v}-linux-arm64-legacy.tar.gz`]) +}) + +test('pattern: linux-armv7l matches exactly one file', () => { + const { pattern } = getDownloadPattern('linux', 'arm', {}) + const matches = releaseFiles.filter(f => pattern.test(f)) + expect(matches).toEqual([`electerm-${v}-linux-armv7l.tar.gz`]) +}) + +test('pattern: linux-armv7l-legacy matches exactly one file', () => { + const { pattern } = getDownloadPattern('linux', 'arm', { linuxLegacy: true }) + const matches = releaseFiles.filter(f => pattern.test(f)) + expect(matches).toEqual([`electerm-${v}-linux-armv7l-legacy.tar.gz`]) +}) + +test('pattern: unsupported platform returns null pattern', () => { + const { pattern, type } = getDownloadPattern('freebsd', 'x64', {}) + expect(pattern).toBe(null) + expect(type).toBe('unsupported') +}) + +// ============================================================================= +// Tests: Extracted Binary Path +// ============================================================================= + +console.log('\n=== Extracted Binary Path Tests ===\n') + +test('getElectermExePath: returns correct path for current platform', () => { + const exePath = getElectermExePath() + if (plat === 'win32') { + expect(exePath).toBe(path.join(_extractDir, 'electerm.exe')) + } else { + expect(exePath).toBe(path.join(_extractDir, 'electerm')) + } +}) + +test('getElectermExePath: extractDir is inside packageRoot', () => { + expect(_extractDir).toContain(_packageRoot) + expect(_extractDir).toBe(path.join(_packageRoot, 'electerm')) +}) + +test('isElectermExtracted: returns boolean', () => { + const result = isElectermExtracted() + expect(typeof result).toBe('boolean') +}) + +// ============================================================================= +// Tests: Utils - Proxy Support +// ============================================================================= + +console.log('\n=== Utils Proxy Tests ===\n') + +test('applyProxy: returns original URL when no proxy configured', () => { + // GITHUB_PROXY is empty by default in tests + const result = applyProxy('https://github.com/test/file.tar.gz') + expect(result).toBe('https://github.com/test/file.tar.gz') +}) + +test('applyProxy: does not proxy non-GitHub URLs', () => { + const result = applyProxy('https://example.com/file.tar.gz') + expect(result).toBe('https://example.com/file.tar.gz') +}) + +test('applyProxy: proxies GitHub URLs when GITHUB_PROXY is set', () => { + // Test the logic directly since module caches GITHUB_PROXY at load time + const proxy = 'https://gh-proxy.com' + const url = 'https://github.com/electerm/electerm/releases/download/v1.0.0/test.tar.gz' + + // Simulate the applyProxy logic + const cleanProxy = proxy.replace(/\/+$/, '') + const result = `${cleanProxy}/${url}` + + expect(result).toBe('https://gh-proxy.com/https://github.com/electerm/electerm/releases/download/v1.0.0/test.tar.gz') +}) + +test('applyProxy: handles proxy URL with trailing slash', () => { + const proxy = 'https://gh-proxy.com/' + const url = 'https://github.com/test/file.tar.gz' + + const cleanProxy = proxy.replace(/\/+$/, '') + const result = `${cleanProxy}/${url}` + + expect(result).toBe('https://gh-proxy.com/https://github.com/test/file.tar.gz') +}) + +test('formatBytes: formats bytes correctly', () => { + expect(formatBytes(0)).toBe('0 B') + expect(formatBytes(1024)).toBe('1 KB') + expect(formatBytes(1048576)).toBe('1 MB') + expect(formatBytes(1073741824)).toBe('1 GB') +}) + +test('formatBytes: handles partial units', () => { + expect(formatBytes(1536)).toBe('1.5 KB') + expect(formatBytes(1572864)).toBe('1.5 MB') +}) + +// ============================================================================= +// Tests: Utils - Tar Extract (sync-friendly) +// ============================================================================= + +console.log('\n=== Utils Tar Extract Tests ===\n') + +test('extractTarGz: extracts a tar.gz file', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-test-tar-')) + const extDir = fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-test-extract-')) + + try { + const testFile = path.join(tmpDir, 'test.txt') + fs.writeFileSync(testFile, 'Hello World') + + const tarFile = path.join(tmpDir, 'test.tar.gz') + await tar.create({ gzip: true, file: tarFile, cwd: tmpDir }, ['test.txt']) + + await extractTarGz(tarFile, extDir) + + expect(fs.existsSync(path.join(extDir, 'test.txt'))).toBe(true) + const content = fs.readFileSync(path.join(extDir, 'test.txt'), 'utf8') + expect(content).toBe('Hello World') + } finally { + fs.rmSync(tmpDir, { recursive: true }) + fs.rmSync(extDir, { recursive: true }) + } +}) + +test('extractTarGz: strips top-level directory with strip:1', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-test-strip-')) + const extDir = fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-test-strip-extract-')) + + try { + const appDir = path.join(tmpDir, 'myapp') + fs.mkdirSync(appDir) + fs.writeFileSync(path.join(appDir, 'test.txt'), 'Stripped!') + + const tarFile = path.join(tmpDir, 'test.tar.gz') + await tar.create({ gzip: true, file: tarFile, cwd: tmpDir }, ['myapp']) + + await extractTarGz(tarFile, extDir, 1) + + expect(fs.existsSync(path.join(extDir, 'test.txt'))).toBe(true) + expect(fs.existsSync(path.join(extDir, 'myapp'))).toBe(false) + } finally { + fs.rmSync(tmpDir, { recursive: true }) + fs.rmSync(extDir, { recursive: true }) + } +}) + +// ============================================================================= +// Tests: CLI Launcher Flow +// ============================================================================= + +console.log('\n=== CLI Launcher Flow Tests ===\n') + +test('node launcher: correct path navigation', () => { + const npmDir = path.join(_packageRoot, 'npm') + const expectedPackageRoot = path.resolve(npmDir, '..') + expect(expectedPackageRoot).toBe(_packageRoot) +}) + +test('node launcher: checks for electerm binary existence', () => { + const expectedBinaryPath = path.join(_packageRoot, 'electerm', 'electerm') + expect(expectedBinaryPath).toBe(getElectermExePath()) +}) + +test('install flow: extractDir is correct', () => { + expect(_extractDir).toBe(path.join(_packageRoot, 'electerm')) +}) + +test('install flow: no infinite recursion', () => { + // install.js does NOT call exec('electerm') + // It only downloads and extracts + // The node launcher then spawns the extracted binary directly + const installExports = require('../../npm/install') + expect(typeof installExports.isElectermExtracted).toBe('function') + expect(typeof installExports.getElectermExePath).toBe('function') +}) + +test('node launcher: launches binary after install', () => { + // The Node.js launcher flow: + // 1. Check if ./electerm/electerm exists + // 2. If not: spawn node ./npm/install.js (downloads & extracts) + // 3. Spawn ./electerm/electerm (launches binary) + // + // This prevents infinite recursion because: + // - install.js never calls 'electerm' command + // - Node.js launcher uses spawn/execFile to run the binary directly + // - npm creates a proper .cmd wrapper on Windows via #!/usr/bin/env node + + const bashScriptPath = path.join(_packageRoot, 'npm', 'electerm') + expect(fs.existsSync(bashScriptPath)).toBe(true) +}) + +// ============================================================================= +// Tests: Cross-Platform Paths +// ============================================================================= + +console.log('\n=== Cross-Platform Path Tests ===\n') + +test('paths: packageRoot is absolute', () => { + expect(path.isAbsolute(_packageRoot)).toBe(true) +}) + +test('paths: extractDir is absolute', () => { + expect(path.isAbsolute(_extractDir)).toBe(true) +}) + +test('paths: extractDir is child of packageRoot', () => { + expect(_extractDir.startsWith(_packageRoot)).toBe(true) +}) + +// ============================================================================= +// Async HTTP Tests (run last to avoid output interleaving) +// ============================================================================= + +console.log('\n=== Utils HTTP Tests (async) ===\n') + +testAsync('httpGet: fetches a URL', async () => { + const result = await httpGet('https://httpbin.org/get', 10000) + expect(typeof result).toBe('string') + expect(result).toContain('httpbin.org') +}) + +testAsync('phin: makes HTTP request', async () => { + const result = await phin({ url: 'https://httpbin.org/get', timeout: 10000 }) + expect(result.statusCode).toBe(200) + expect(Buffer.isBuffer(result.body)).toBe(true) + expect(result.body.toString()).toContain('httpbin.org') +}) + +testAsync('httpGet: handles redirects', async () => { + const result = await httpGet('https://httpbin.org/redirect/1', 10000) + expect(typeof result).toBe('string') +}) + +testAsync('httpGet: throws on 404', async () => { + try { + await httpGet('https://httpbin.org/status/404', 10000) + throw new Error('Expected to throw') + } catch (e) { + expect(e.message).toContain('404') + } +}) + +testAsync('download: downloads a file', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-test-dl-')) + try { + const result = await download( + 'https://httpbin.org/robots.txt', + tmpDir, + { extract: false } + ) + expect(result.filepath).toContain('robots.txt') + expect(result.extracted).toBe(false) + expect(fs.existsSync(result.filepath)).toBe(true) + } finally { + fs.rmSync(tmpDir, { recursive: true }) + } +}) + +// ============================================================================= +// Run all async tests and print results +// ============================================================================= + +async function runAsyncTests () { + for (const fn of asyncQueue) { + await fn() + } +} + +async function printResults () { + await runAsyncTests() + + console.log('\n========================================') + console.log(`Results: ${passed} passed, ${failed} failed`) + console.log('========================================\n') + + process.exit(failed > 0 ? 1 : 0) +} + +printResults().catch(err => { + console.error('Test runner error:', err) + process.exit(1) +})
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/electerm/electerm/commit/59708b38c8a52f5db59d7d4eff98e31d573128eenvdPatch
- github.com/electerm/electerm/security/advisories/GHSA-8x35-hph8-37hqnvdPatchVendor Advisory
- github.com/advisories/GHSA-8x35-hph8-37hqghsaADVISORY
- github.com/electerm/electerm/releases/tag/v3.3.8nvdRelease Notes
- nvd.nist.gov/vuln/detail/CVE-2026-41501ghsa
News mentions
0No linked articles in our index yet.