Source: index.js

const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const os = require('os');
const axios = require('axios');
const tar = require('tar');
const yauzl = require('yauzl');
const semver = require('semver');
const { promisify } = require('util');
const { pipeline } = require('stream');
const crypto = require('crypto');
const pipelineAsync = promisify(pipeline);

/**
 * Main class for building Node.js applications into single executable files using SEA (Single Executable Applications)
 * Supports cross-platform builds for Linux, macOS, and Windows
 * @class NodePackageBuilder
 */
class NodePackageBuilder {
  /**
   * Create a new NodePackageBuilder instance
   * @param {Object} [options={}] - Build configuration options
   * @param {string} [options.main='index.js'] - Main JavaScript file to build
   * @param {string} [options.output='app'] - Output executable name
   * @param {boolean} [options.disableExperimentalSEAWarning=true] - Disable experimental SEA warning
   * @param {boolean} [options.useSnapshot=false] - Enable snapshot support
   * @param {boolean} [options.useCodeCache=false] - Enable code cache
   * @param {Object} [options.assets={}] - Assets to include in the executable
   * @param {string[]} [options.platforms=['linux', 'darwin', 'win32']] - Target platforms to build for
   * @param {string} [options.tempDir] - Temporary directory for build files
   */
  constructor(options = {}) {
    this.options = {
      main: options.main || 'index.js',
      output: options.output || 'app',
      disableExperimentalSEAWarning: options.disableExperimentalSEAWarning || true,
      useSnapshot: options.useSnapshot || false,
      useCodeCache: options.useCodeCache || false,
      assets: options.assets || {},
      platforms: options.platforms || ['linux', 'darwin', 'win32'],
      tempDir: options.tempDir || path.join(os.tmpdir(), 'node-package-builder'),
      ...options
    };
    
    this.buildId = this.generateBuildId();
    this.tempBuildDir = path.join(this.options.tempDir, this.buildId);
    
    this.checkNodeVersion();
    this.ensureTempDir();
  }
  
  /**
   * Generate a unique build identifier using timestamp and random bytes
   * @returns {string} Unique build ID string
   */
  generateBuildId() {
    const timestamp = Date.now();
    const random = crypto.randomBytes(4).toString('hex');
    return `build-${timestamp}-${random}`;
  }

  /**
   * Ensure temporary directories exist, creating them if necessary
   */
  ensureTempDir() {
    if (!fs.existsSync(this.options.tempDir)) {
      fs.mkdirSync(this.options.tempDir, { recursive: true });
    }
    if (!fs.existsSync(this.tempBuildDir)) {
      fs.mkdirSync(this.tempBuildDir, { recursive: true });
    }
  }

  /**
   * Check if current Node.js version meets minimum requirements
   * @throws {Error} If Node.js version is too old
   */
  checkNodeVersion() {
    const currentVersion = process.version.slice(1);
    const requiredVersion = '19.9.0';
    
    if (!this.isVersionGreaterOrEqual(currentVersion, requiredVersion)) {
      throw new Error(`Node.js version ${requiredVersion} or higher is required. Current version: ${currentVersion}`);
    }
  }
  
  /**
   * Compare version strings to determine if current version meets requirements
   * @param {string} current - Current version string
   * @param {string} required - Required minimum version string
   * @returns {boolean} True if current version is greater than or equal to required
   */
  isVersionGreaterOrEqual(current, required) {
    const currentParts = current.split('.').map(Number);
    const requiredParts = required.split('.').map(Number);
    
    for (let i = 0; i < Math.max(currentParts.length, requiredParts.length); i++) {
      const currentPart = currentParts[i] || 0;
      const requiredPart = requiredParts[i] || 0;
      
      if (currentPart > requiredPart) return true;
      if (currentPart < requiredPart) return false;
    }
    
    return true;
  }

  /**
   * Build executables for all configured platforms
   * @returns {Promise<Array<{platform: string, success: boolean, executable: string, path: string, buildId: string, error: string}>>} Promise that resolves to an array of build results
   */
  async build() {
    const results = [];
    
    try {
      for (const platform of this.options.platforms) {
        try {
          const result = await this.buildForPlatform(platform);
          results.push(result);
        } catch (error) {
          console.error(`Failed to build for ${platform}:`, error.message);
          results.push({ platform, success: false, error: error.message });
        }
      }
      
      return results;
    } finally {
      this.cleanupTempDir();
    }
  }

  /**
   * Build executable for a specific platform
   * @param {string} platform - Target platform ('linux', 'darwin', or 'win32')
   * @returns {Promise<{platform: string, success: boolean, executable: string, path: string, buildId: string}>} Promise that resolves to build result
   * @throws {Error} If build process fails
   */
  async buildForPlatform(platform) {
    const configPath = this.createSeaConfig(platform);
    const blobPath = path.join(this.tempBuildDir, `sea-prep-${platform}.blob`);
    const executableName = this.getExecutableName(platform);
    
    try {
      this.generateBlob(configPath);
      
      await this.createExecutable(platform, executableName);
      
      await this.injectBlob(platform, executableName, blobPath);
      
      if (platform === 'darwin' || platform === 'win32') {
        await this.signExecutable(platform, executableName);
      }
      
      this.cleanup([configPath, blobPath]);
      
      return {
        platform,
        success: true,
        executable: executableName,
        path: path.resolve(executableName),
        buildId: this.buildId
      };
    } catch (error) {
      this.cleanup([configPath, blobPath]);
      throw error;
    }
  }

  /**
   * Create SEA (Single Executable Application) configuration file for a platform
   * @param {string} platform - Target platform
   * @returns {string} Path to the created configuration file
   */
  createSeaConfig(platform) {
    const config = {
      main: this.options.main,
      output: path.join(this.tempBuildDir, `sea-prep-${platform}.blob`),
      disableExperimentalSEAWarning: this.options.disableExperimentalSEAWarning,
      useSnapshot: this.options.useSnapshot,
      useCodeCache: this.options.useCodeCache
    };

    if (platform !== process.platform) {
      config.useSnapshot = false;
      config.useCodeCache = false;
    }

    if (Object.keys(this.options.assets).length > 0) {
      config.assets = this.options.assets;
    }

    const configPath = path.join(this.tempBuildDir, `sea-config-${platform}-${this.buildId}.json`);
    fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
    return configPath;
  }

  /**
   * Generate blob file from SEA configuration using Node.js
   * @param {string} configPath - Path to SEA configuration file
   * @throws {Error} If blob generation fails
   */
  generateBlob(configPath) {
    try {
      const nodeExecutable = process.execPath;
      execSync(`"${nodeExecutable}" --experimental-sea-config ${configPath}`, {
        stdio: 'inherit',
        cwd: process.cwd()
      });
    } catch (error) {
      throw new Error(`Failed to generate blob: ${error.message}`);
    }
  }

  /**
   * Create executable file for target platform by copying Node.js binary
   * @param {string} platform - Target platform
   * @param {string} executableName - Name of the executable to create
   * @throws {Error} If executable creation fails
   */
  async createExecutable(platform, executableName) {
    let nodePath;
    
    if (platform === process.platform) {
      nodePath = process.execPath;
    } else {
      nodePath = await this.downloadNodeBinary(platform);
    }
    
    if (platform === 'win32' && !executableName.endsWith('.exe')) {
      executableName += '.exe';
    }
    
    try {
      fs.copyFileSync(nodePath, executableName);
    } catch (error) {
      throw new Error(`Failed to copy Node.js executable from '${nodePath}' to '${executableName}': ${error.message}`);
    }

    if (platform === 'darwin') {
      try {
        execSync(`codesign --remove-signature "${executableName}"`, { stdio: 'ignore' });
      } catch (error) {
        console.warn('Warning: Could not remove signature. Continuing...');
      }
    }

    if (platform === 'win32') {
      try {
        execSync(`signtool remove /s "${executableName}"`, { stdio: 'ignore' });
      } catch (error) {
        console.warn('Warning: Could not remove signature. Continuing...');
      }
    }
  }

  /**
   * Inject blob into executable using postject tool
   * @param {string} platform - Target platform
   * @param {string} executableName - Name of the executable
   * @param {string} blobPath - Path to the blob file to inject
   * @throws {Error} If blob injection fails
   */
  async injectBlob(platform, executableName, blobPath) {
    let command;
    const sentinelFuse = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2';
    
    if (platform === 'darwin') {
      command = `npx postject "${executableName}" NODE_SEA_BLOB "${blobPath}" --sentinel-fuse ${sentinelFuse} --macho-segment-name NODE_SEA`;
    } else {
      command = `npx postject "${executableName}" NODE_SEA_BLOB "${blobPath}" --sentinel-fuse ${sentinelFuse}`;
    }

    try {
      execSync(command, { stdio: 'inherit' });
      
      if (platform === 'win32') {
        await this.verifyWindowsExecutable(executableName);
      }
    } catch (error) {
      throw new Error(`Failed to inject blob: ${error.message}`);
    }
  }
  
  /**
   * Verify Windows executable is working correctly and not in REPL mode
   * @param {string} executableName - Name of the executable to verify
   * @throws {Error} If executable is still in REPL mode
   */
  async verifyWindowsExecutable(executableName) {
    try {
      const result = execSync(`"${path.resolve(executableName)}" --help`, { 
        encoding: 'utf8', 
        timeout: 5000,
        stdio: 'pipe'
      });
      
      if (result.includes('Welcome to Node.js') || result.includes('Type ".help"')) {
        throw new Error('Windows executable is still in REPL mode - blob injection failed');
      }
    } catch (error) {
      if (error.message.includes('REPL mode')) {
        throw error;
      }
    }
  }

  /**
   * Sign executable for distribution (macOS and Windows)
   * @param {string} platform - Target platform
   * @param {string} executableName - Name of the executable to sign
   */
  async signExecutable(platform, executableName) {
    try {
      if (platform === 'darwin') {
        execSync(`codesign --sign - "${executableName}"`, { stdio: 'ignore' });
      } else if (platform === 'win32') {
        execSync(`signtool sign /fd SHA256 "${executableName}"`, { stdio: 'ignore' });
      }
    } catch (error) {
      console.warn(`Warning: Could not sign executable for ${platform}. The executable should still work.`);
    }
  }

  /**
   * Get executable name for platform with appropriate extension
   * @param {string} platform - Target platform
   * @returns {string} Executable name with platform-specific extension
   */
  getExecutableName(platform) {
    const baseName = this.options.output;
    if (platform === 'win32') {
      return baseName.endsWith('.exe') ? baseName : `${baseName}.exe`;
    }
    return baseName;
  }

  /**
   * Download Node.js binary for target platform with caching support
   * @param {string} platform - Target platform
   * @returns {Promise<string>} Promise that resolves to path of downloaded Node.js binary
   * @throws {Error} If download or extraction fails
   */
  async downloadNodeBinary(platform) {
    const version = await this.getRecommendedNodeVersion();
    const cacheDir = path.join(os.homedir(), '.node-package-builder', 'cache');
    const platformDir = path.join(cacheDir, platform);
    const versionDir = path.join(platformDir, version);
    
    if (!fs.existsSync(cacheDir)) {
      fs.mkdirSync(cacheDir, { recursive: true });
    }
    
    if (!fs.existsSync(platformDir)) {
      fs.mkdirSync(platformDir, { recursive: true });
    }
    
    const executableName = platform === 'win32' ? 'node.exe' : 'node';
    const executablePath = path.join(versionDir, executableName);
    
    if (fs.existsSync(executablePath)) {
      console.log(`Using cached Node.js ${version} for ${platform}`);
      return executablePath;
    }
    
    console.log(`Downloading Node.js ${version} for ${platform}...`);
    
    const downloadUrl = this.getNodeDownloadUrl(version, platform);
    const archivePath = path.join(platformDir, `node-${version}-${platform}.${platform === 'win32' ? 'zip' : 'tar.gz'}`);
    
    await this.downloadFile(downloadUrl, archivePath);
    
    if (!fs.existsSync(versionDir)) {
      fs.mkdirSync(versionDir, { recursive: true });
    }
    
    await this.extractNodeBinary(archivePath, versionDir, platform, version);
    
    fs.unlinkSync(archivePath);
    
    if (!fs.existsSync(executablePath)) {
      throw new Error(`Failed to extract Node.js executable for ${platform}`);
    }
    
    if (platform !== 'win32') {
      fs.chmodSync(executablePath, '755');
    }
    
    return executablePath;
  }
  
  /**
   * Get recommended Node.js version from official registry with fallback
   * @returns {Promise<string>} Promise that resolves to recommended Node.js version string
   */
  async getRecommendedNodeVersion() {
    try {
      const response = await axios.get('https://nodejs.org/dist/index.json');
      const versions = response.data;
      
      const minVersion = '19.9.0';
      const maxVersion = '22.99.99';
      const validVersions = versions.filter(v => {
        const version = v.version.slice(1);
        return semver.gte(version, minVersion) && 
               semver.lte(version, maxVersion) &&
               !v.version.includes('rc') && 
               !v.version.includes('beta');
      });
      
      if (validVersions.length === 0) {
        throw new Error(`No valid Node.js versions found (minimum: ${minVersion})`);
      }
      
      const preferredVersions = ['20.18.0', '20.17.0', '20.16.0', '20.15.1'];
      for (const preferred of preferredVersions) {
        const found = validVersions.find(v => v.version.slice(1) === preferred);
        if (found) {
          return found.version.slice(1);
        }
      }
      
      const latestLTS = validVersions.find(v => v.lts !== false && semver.major(v.version.slice(1)) === 20);
      if (latestLTS) {
        return latestLTS.version.slice(1);
      }
      
      return validVersions[0].version.slice(1);
    } catch (error) {
      console.warn('Failed to fetch latest Node.js version, using fallback');
      return '20.18.0';
    }
  }
  
  /**
   * Get download URL for Node.js binary for specific version and platform
   * @param {string} version - Node.js version
   * @param {string} platform - Target platform
   * @returns {string} Download URL for Node.js binary
   * @throws {Error} If platform is unsupported
   */
  getNodeDownloadUrl(version, platform) {
    const arch = 'x64';
    const baseUrl = 'https://nodejs.org/dist';
    
    if (platform === 'win32') {
      return `${baseUrl}/v${version}/node-v${version}-win-${arch}.zip`;
    } else if (platform === 'darwin') {
      return `${baseUrl}/v${version}/node-v${version}-darwin-${arch}.tar.gz`;
    } else if (platform === 'linux') {
      return `${baseUrl}/v${version}/node-v${version}-linux-${arch}.tar.gz`;
    } else {
      throw new Error(`Unsupported platform: ${platform}`);
    }
  }
  
  /**
   * Download file from URL to local path using streaming
   * @param {string} url - File URL to download
   * @param {string} filePath - Destination file path
   * @returns {Promise<void>} Promise that resolves when download completes
   */
  async downloadFile(url, filePath) {
    const response = await axios({
      method: 'GET',
      url: url,
      responseType: 'stream'
    });
    
    const writer = fs.createWriteStream(filePath);
    await pipelineAsync(response.data, writer);
  }
  
  /**
   * Extract Node.js binary from archive file (ZIP for Windows, tar.gz for Unix)
   * @param {string} archivePath - Path to archive file
   * @param {string} extractDir - Directory to extract to
   * @param {string} platform - Target platform
   * @param {string} version - Node.js version
   * @returns {Promise<void>} Promise that resolves when extraction completes
   */
  async extractNodeBinary(archivePath, extractDir, platform, version) {
    if (platform === 'win32') {
      await this.extractZip(archivePath, extractDir, version, platform);
    } else {
      await this.extractTarGz(archivePath, extractDir, version, platform);
    }
  }
  
  /**
   * Extract Node.js binary from ZIP archive (Windows)
   * @param {string} zipPath - Path to ZIP file
   * @param {string} extractDir - Directory to extract to
   * @param {string} version - Node.js version
   * @param {string} platform - Target platform
   * @returns {Promise<void>} Promise that resolves when extraction completes
   */
  async extractZip(zipPath, extractDir, version, platform) {
    return new Promise((resolve, reject) => {
      yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
        if (err) return reject(err);
        
        zipfile.readEntry();
        zipfile.on('entry', (entry) => {
          if (entry.fileName.endsWith('node.exe')) {
            zipfile.openReadStream(entry, (err, readStream) => {
              if (err) return reject(err);
              
              const outputPath = path.join(extractDir, 'node.exe');
              const writeStream = fs.createWriteStream(outputPath);
              
              readStream.pipe(writeStream);
              writeStream.on('close', () => {
                zipfile.close();
                resolve();
              });
              writeStream.on('error', reject);
            });
          } else {
            zipfile.readEntry();
          }
        });
        
        zipfile.on('end', resolve);
        zipfile.on('error', reject);
      });
    });
  }
  
  /**
   * Extract Node.js binary from tar.gz archive (Unix systems)
   * @param {string} tarPath - Path to tar.gz file
   * @param {string} extractDir - Directory to extract to
   * @param {string} version - Node.js version
   * @param {string} platform - Target platform
   * @returns {Promise<void>} Promise that resolves when extraction completes
   */
  async extractTarGz(tarPath, extractDir, version, platform) {
    const folderName = `node-v${version}-${platform}-x64`;
    const nodeBinaryPath = `${folderName}/bin/node`;
    
    await tar.extract({
      file: tarPath,
      cwd: extractDir,
      filter: (path) => path === nodeBinaryPath
    });
    
    const extractedPath = path.join(extractDir, nodeBinaryPath);
    const finalPath = path.join(extractDir, 'node');
    
    if (fs.existsSync(extractedPath)) {
      fs.renameSync(extractedPath, finalPath);
      
      const folderPath = path.join(extractDir, folderName);
      if (fs.existsSync(folderPath)) {
        fs.rmSync(folderPath, { recursive: true, force: true });
      }
    }
  }

  /**
   * Clean up temporary files
   * @param {string[]} files - Array of file paths to clean up
   */
  cleanup(files) {
    files.forEach(file => {
      try {
        if (fs.existsSync(file)) {
          fs.unlinkSync(file);
        }
      } catch (error) {
        console.warn(`Warning: Could not clean up ${file}`);
      }
    });
  }

  /**
   * Clean up temporary build directory for this instance
   */
  cleanupTempDir() {
    try {
      if (fs.existsSync(this.tempBuildDir)) {
        fs.rmSync(this.tempBuildDir, { recursive: true, force: true });
        console.log(`Cleaned up temporary build directory: ${this.tempBuildDir}`);
      }
    } catch (error) {
      console.warn(`Warning: Could not clean up temp directory ${this.tempBuildDir}: ${error.message}`);
    }
  }

  /**
   * Clean up all temporary build directories from previous builds
   * @static
   */
  static cleanupAllTempDirs() {
    try {
      const tempDir = path.join(os.tmpdir(), 'node-package-builder');
      if (fs.existsSync(tempDir)) {
        const buildDirs = fs.readdirSync(tempDir).filter(dir => dir.startsWith('build-'));
        buildDirs.forEach(buildDir => {
          const fullPath = path.join(tempDir, buildDir);
          try {
            fs.rmSync(fullPath, { recursive: true, force: true });
            console.log(`Cleaned up old build directory: ${fullPath}`);
          } catch (error) {
            console.warn(`Warning: Could not clean up ${fullPath}: ${error.message}`);
          }
        });
      }
    } catch (error) {
      console.warn(`Warning: Could not clean up temp directories: ${error.message}`);
    }
  }

  /**
   * Get list of supported target platforms
   * @static
   * @returns {string[]} Array of supported platform names
   */
  static getSupportedPlatforms() {
    return ['linux', 'darwin', 'win32'];
  }

  /**
   * Validate that main file exists and is accessible
   * @static
   * @param {string} mainFile - Path to main file to validate
   * @throws {Error} If main file is invalid or doesn't exist
   */
  static validateMainFile(mainFile) {
    if (!fs.existsSync(mainFile)) {
      throw new Error(`Main file not found: ${mainFile}`);
    }
    
    const stats = fs.statSync(mainFile);
    if (!stats.isFile()) {
      throw new Error(`Main file is not a file: ${mainFile}`);
    }
  }
}

module.exports = NodePackageBuilder;
module.exports.NodePackageBuilder = NodePackageBuilder;
module.exports.getSupportedPlatforms = NodePackageBuilder.getSupportedPlatforms;
module.exports.validateMainFile = NodePackageBuilder.validateMainFile;
module.exports.cleanupAllTempDirs = NodePackageBuilder.cleanupAllTempDirs;