/** * 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, 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()}` // 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 phin({ url: versionUrl, timeout: 15000 }) .then(res => res.body.toString()) } function getReleaseInfo (filter) { return phin({ url: releaseInfoUrl, timeout: 15000 }) .then((res) => { return JSON.parse(res.body.toString()) .release .assets .filter(filter)[0] }) } function showFinalMessage () { console.log('') console.log('========================================') console.log('electerm installation complete!') console.log('========================================') console.log('') console.log('For more information, documentation, and updates, please visit:') console.log('https://electerm.org') console.log('') console.log('Thank you for using electerm!') console.log('========================================') console.log('') } // --------------------------------------------------------------------------- // Platform detection helpers // --------------------------------------------------------------------------- function isWindows7OrEarlier (platform, release) { if (platform !== 'win32') return false const [major, minor] = release.split('.').map(Number) return major < 10 && (major < 6 || (major === 6 && minor <= 1)) } function isMacOS10 (platform, release) { if (platform !== 'darwin') return false const majorVersion = parseInt(release.split('.')[0], 10) return majorVersion < 20 } function isLinuxLegacy (platform) { if (platform !== 'linux') return false try { const result = execSync('ldd --version 2>&1 | head -n1', { encoding: 'utf8' }) const match = result.match(/(\d+\.\d+)\s*$/) if (match) { return parseFloat(match[1]) < 2.34 } return false } catch (e) { return false } } // --------------------------------------------------------------------------- // Launch the extracted binary // --------------------------------------------------------------------------- /** * Get the path to the extracted electerm executable */ function getElectermExePath () { if (plat === 'win32') { return join(extractDir, 'electerm.exe') } // 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 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}`) } // 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() } 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 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.`) } showFinalMessage() } 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 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.`) } showFinalMessage() } /** * Mount a DMG, copy the .app to /Applications, then detach * @param {string} dmgPath - Path to the DMG file * @returns {Promise} - 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 Mac ${archName}`) } 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() // 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]) } } 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 macOS 10.x') } 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() // 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('') 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)') console.log(' Fetching release information...') try { if (plat === 'win32') { if (win7) { await runWin7() } else if (arch === 'arm64') { await runWin('arm64') } else { await runWin('x64') } } else if (plat === 'darwin') { if (mac10) { await runMac10() } else if (arch === 'arm64') { await runMac('arm64') } else { await runMac('x64') } } else if (plat === 'linux') { const suffix = linuxLegacy ? '-legacy' : '' 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 if (arch.includes('loong')) { await runLinux(`linux-loong64${suffix}`, `linux-loong64${suffix}.tar.gz`) } else { await runLinux(`linux-x64${suffix}`, `linux-x64${suffix}.tar.gz`) } } else { throw new Error(`Platform "${plat}" is not supported.`) } } catch (err) { console.error('') console.error('========================================') console.error('Installation failed!') console.error('========================================') console.error(`Error: ${err.message}`) console.error(`Platform: ${plat}, Architecture: ${arch}`) console.error('') console.error('Please visit https://electerm.org for manual download options.') console.error('========================================') console.error('') process.exit(1) } } // --------------------------------------------------------------------------- // Exports for testing // --------------------------------------------------------------------------- module.exports = { isWindows7OrEarlier, isMacOS10, isLinuxLegacy, sanitizeVersion, sanitizeFilename, getElectermExePath, isElectermExtracted, // Expose for test injection _packageRoot: packageRoot, _extractDir: extractDir } if (require.main === module) { main() }