/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { spawnSync } from 'node:child_process'; import { cpSync, rmSync, mkdirSync, existsSync, copyFileSync, writeFileSync, readFileSync, } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import process from 'node:process'; import { globSync } from 'glob'; import { createHash } from 'node:crypto'; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, '..'); const distDir = join(root, 'dist'); const bundleDir = join(root, 'bundle'); const stagingDir = join(bundleDir, 'native_modules'); const seaConfigPath = join(root, 'sea-config.json'); const manifestPath = join(bundleDir, 'manifest.json'); const entitlementsPath = join(root, 'scripts/entitlements.plist'); // --- Helper Functions --- /** * Safely executes a command using spawnSync. * @param {string} command * @param {string[]} args * @param {object} options */ function runCommand(command, args, options = {}) { let finalCommand = command; let useShell = options.shell || false; // On Windows, npm/npx are batch files and need a shell if ( process.platform === 'win32' && (command === 'npm' || command === 'npx') ) { finalCommand = `${command}.cmd`; useShell = true; } const finalOptions = { stdio: 'inherit', cwd: root, shell: useShell, ...options, }; const result = spawnSync(finalCommand, args, finalOptions); if (result.status !== 0) { if (result.error) { throw result.error; } throw new Error( `Command failed with exit code ${result.status}: ${command}`, ); } return result; } /** * Removes existing digital signatures from a binary. * @param {string} filePath */ function removeSignature(filePath) { console.log(`Removing signature from ${filePath}...`); const platform = process.platform; try { if (platform === 'darwin') { spawnSync('codesign', ['--remove-signature', filePath], { stdio: 'ignore', }); } else if (platform === 'win32') { spawnSync('signtool', ['remove', '/s', filePath], { stdio: 'ignore', }); } } catch { // Best effort: Ignore failures } } /** * Signs a binary using hardcoded tools for the platform. * @param {string} filePath */ function signFile(filePath) { const platform = process.platform; if (platform === 'darwin') { const identity = process.env.APPLE_IDENTITY || '-'; console.log(`Signing ${filePath} (Identity: ${identity})...`); const args = [ '--sign', identity, '--force', '--timestamp', '--options', 'runtime', ]; if (existsSync(entitlementsPath)) { args.push('--entitlements', entitlementsPath); } args.push(filePath); runCommand('codesign', args); } else if (platform === 'win32') { const args = ['sign']; if (process.env.WINDOWS_PFX_FILE && process.env.WINDOWS_PFX_PASSWORD) { args.push( '/f', process.env.WINDOWS_PFX_FILE, '/p', process.env.WINDOWS_PFX_PASSWORD, ); } else { args.push('/a'); } args.push( '/fd', 'SHA256', '/td', 'SHA256', '/tr', 'http://timestamp.digicert.com', filePath, ); console.log(`Signing ${filePath}...`); try { runCommand('signtool', args, { stdio: 'pipe' }); } catch (e) { let msg = e.message; if (process.env.WINDOWS_PFX_PASSWORD) { msg = msg.replaceAll(process.env.WINDOWS_PFX_PASSWORD, '******'); } throw new Error(msg); } } else if (platform === 'linux') { console.log(`Skipping signing for ${filePath} on Linux.`); } } console.log('Build Binary Script Started...'); // 1. Clean dist if (existsSync(distDir)) { console.log('Cleaning dist directory...'); rmSync(distDir, { recursive: true, force: true }); } mkdirSync(distDir, { recursive: true }); // 2. Build Bundle console.log('Running npm clean, install, and bundle...'); try { runCommand('npm', ['run', 'clean']); runCommand('npm', ['install']); runCommand('npm', ['run', 'bundle']); } catch (e) { console.error('Build step failed:', e.message); process.exit(1); } // 3. Stage & Sign Native Modules const includeNativeModules = process.env.BUNDLE_NATIVE_MODULES !== 'false'; console.log(`Include Native Modules: ${includeNativeModules}`); if (includeNativeModules) { console.log('Staging and signing native modules...'); // Prepare staging if (existsSync(stagingDir)) rmSync(stagingDir, { recursive: true, force: true }); mkdirSync(stagingDir, { recursive: true }); // Copy @lydell/node-pty to staging const lydellSrc = join(root, 'node_modules/@lydell'); const lydellStaging = join(stagingDir, 'node_modules/@lydell'); if (existsSync(lydellSrc)) { mkdirSync(dirname(lydellStaging), { recursive: true }); cpSync(lydellSrc, lydellStaging, { recursive: true }); } else { console.warn( 'Warning: @lydell/node-pty not found in node_modules. Native terminal features may fail.', ); } // Sign Staged .node files try { const nodeFiles = globSync('**/*.node', { cwd: stagingDir, absolute: true, }); for (const file of nodeFiles) { signFile(file); } } catch (e) { console.warn('Warning: Failed to sign native modules:', e.code); } } else { console.log('Skipping native modules bundling (BUNDLE_NATIVE_MODULES=false)'); } // 4. Generate SEA Configuration and Manifest console.log('Generating SEA configuration and manifest...'); const packageJson = JSON.parse( readFileSync(join(root, 'package.json'), 'utf8'), ); // Helper to calc hash const sha256 = (content) => createHash('sha256').update(content).digest('hex'); // Read Main Bundle const geminiBundlePath = join(root, 'bundle/gemini.js'); const geminiContent = readFileSync(geminiBundlePath); const geminiHash = sha256(geminiContent); const assets = { 'gemini.mjs': geminiBundlePath, // Use .js source but map to .mjs for runtime ESM 'manifest.json': 'bundle/manifest.json', }; const manifest = { main: 'gemini.mjs', mainHash: geminiHash, version: packageJson.version, files: [], }; // Helper to recursively find files from STAGING function addAssetsFromDir(baseDir, runtimePrefix) { const fullDir = join(stagingDir, baseDir); if (!existsSync(fullDir)) return; const items = globSync('**/*', { cwd: fullDir, nodir: true }); for (const item of items) { const relativePath = join(runtimePrefix, item); const assetKey = `files:${relativePath}`; const fsPath = join(fullDir, item); // Calc hash const content = readFileSync(fsPath); const hash = sha256(content); assets[assetKey] = fsPath; manifest.files.push({ key: assetKey, path: relativePath, hash: hash }); } } // Add sb files const sbFiles = globSync('sandbox-macos-*.sb', { cwd: bundleDir }); for (const sbFile of sbFiles) { const fsPath = join(bundleDir, sbFile); const content = readFileSync(fsPath); const hash = sha256(content); assets[sbFile] = fsPath; manifest.files.push({ key: sbFile, path: sbFile, hash: hash }); } // Add policy files const policyDir = join(bundleDir, 'policies'); if (existsSync(policyDir)) { const policyFiles = globSync('*.toml', { cwd: policyDir }); for (const policyFile of policyFiles) { const fsPath = join(policyDir, policyFile); const relativePath = join('policies', policyFile); const content = readFileSync(fsPath); const hash = sha256(content); // Use a unique key to avoid collision if filenames overlap (though unlikely here) // But sea-launch writes to 'path', so key is just for lookup. const assetKey = `policies:${policyFile}`; assets[assetKey] = fsPath; manifest.files.push({ key: assetKey, path: relativePath, hash: hash }); } } // Add assets from Staging if (includeNativeModules) { addAssetsFromDir('node_modules/@lydell', 'node_modules/@lydell'); } writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); const seaConfig = { main: 'sea/sea-launch.cjs', output: 'dist/sea-prep.blob', disableExperimentalSEAWarning: true, assets: assets, }; writeFileSync(seaConfigPath, JSON.stringify(seaConfig, null, 2)); console.log(`Configured ${Object.keys(assets).length} embedded assets.`); // 5. Generate SEA Blob console.log('Generating SEA blob...'); try { runCommand('node', ['--experimental-sea-config', 'sea-config.json']); } catch (e) { console.error('Failed to generate SEA blob:', e.message); // Cleanup if (existsSync(seaConfigPath)) rmSync(seaConfigPath); if (existsSync(manifestPath)) rmSync(manifestPath); if (existsSync(stagingDir)) rmSync(stagingDir, { recursive: true, force: true }); process.exit(1); } // Check blob existence const blobPath = join(distDir, 'sea-prep.blob'); if (!existsSync(blobPath)) { console.error('Error: sea-prep.blob not found in dist/'); process.exit(1); } // 6. Identify Target & Prepare Binary const platform = process.platform; const arch = process.arch; const targetName = `${platform}-${arch}`; console.log(`Targeting: ${targetName}`); const targetDir = join(distDir, targetName); mkdirSync(targetDir, { recursive: true }); const nodeBinary = process.execPath; const binaryName = platform === 'win32' ? 'gemini.exe' : 'gemini'; const targetBinaryPath = join(targetDir, binaryName); console.log(`Copying node binary from ${nodeBinary} to ${targetBinaryPath}...`); copyFileSync(nodeBinary, targetBinaryPath); // Remove existing signature using helper removeSignature(targetBinaryPath); // Copy standard bundle assets (policies, .sb files) console.log('Copying additional resources...'); if (existsSync(bundleDir)) { cpSync(bundleDir, targetDir, { recursive: true }); } // Clean up source JS files from output (we only want embedded) const filesToRemove = [ 'gemini.js', 'gemini.mjs', 'gemini.js.map', 'gemini.mjs.map', 'gemini-sea.cjs', 'sea-launch.cjs', 'manifest.json', 'native_modules', 'policies', ]; filesToRemove.forEach((f) => { const p = join(targetDir, f); if (existsSync(p)) rmSync(p, { recursive: true, force: true }); }); // Remove .sb files from targetDir const sbFilesToRemove = globSync('sandbox-macos-*.sb', { cwd: targetDir }); for (const f of sbFilesToRemove) { rmSync(join(targetDir, f)); } // 7. Inject Blob console.log('Injecting SEA blob...'); const sentinelFuse = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2'; try { const args = [ 'postject', targetBinaryPath, 'NODE_SEA_BLOB', blobPath, '--sentinel-fuse', sentinelFuse, ]; if (platform === 'darwin') { args.push('--macho-segment-name', 'NODE_SEA'); } runCommand('npx', args); console.log('Injection successful.'); } catch (e) { console.error('Postject failed:', e.message); process.exit(1); } // 8. Final Signing console.log('Signing final executable...'); try { signFile(targetBinaryPath); } catch (e) { console.warn('Warning: Final signing failed:', e.code); console.warn('Continuing without signing...'); } // 9. Cleanup console.log('Cleaning up artifacts...'); rmSync(blobPath); if (existsSync(seaConfigPath)) rmSync(seaConfigPath); if (existsSync(manifestPath)) rmSync(manifestPath); if (existsSync(stagingDir)) rmSync(stagingDir, { recursive: true, force: true }); console.log(`Binary built successfully in ${targetDir}`);