VYPR
Critical severity9.8GHSA Advisory· Published May 8, 2026· Updated May 8, 2026

CVE-2026-41500

CVE-2026-41500

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:150. The runMac() function appends attacker-controlled remote releaseInfo.name directly into an exec("open ...") command without validation. This issue has been patched in version 3.3.8.

Affected products

2

Patches

1
59708b38c8a5

Fix npm install.js security issue, support `npm i -g electerm` to deploy electerm binary and run electerm command in all OS (#4287)

https://github.com/electerm/electermZHAO XudongApr 15, 2026via nvd-ref
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

News mentions

0

No linked articles in our index yet.