node-msix-packager
Version:
A utility to create MSIX packages from Node.js applications with MCP server support, Node.js Single Executable Application (SEA) bundling using @vercel/ncc and postject, and enhanced build options
304 lines (252 loc) • 10.5 kB
JavaScript
const path = require('path');
const fs = require('fs-extra');
const { executeCommand } = require('./utils');
/**
* Creates a Single Executable Application (SEA) using Node.js built-in capabilities with NCC bundling
* @param {string} packageDir - Directory containing the package
* @param {Object} config - Configuration object
* @param {Object} packageJson - package.json content
* @returns {Promise<boolean>} Success status
*/
async function createSingleExecutableApp(packageDir, config, packageJson) {
try {
console.log('🔨 Creating Single Executable Application (SEA) with NCC bundling...');
const appName = config.appName || 'app';
const executableName = `${appName.replace(/[^a-zA-Z0-9.-]/g, '_')}.exe`;
const executablePath = path.join(packageDir, executableName);
// Step 1: Install NCC if not available
console.log('📦 Installing @vercel/ncc...');
try {
executeCommand('npm install @vercel/ncc --no-save', { cwd: packageDir });
} catch (nccError) {
console.log('Installing NCC globally...');
executeCommand('npm install -g @vercel/ncc', { cwd: packageDir });
}
// Step 2: Create entry point that handles both SEA and regular execution
const mainScript = packageJson.main || 'index.js';
const entryContent = `#!/usr/bin/env node
// SEA Entry Point for ${config.displayName || config.appName}
console.log('🚀 Starting ${config.displayName || config.appName}...');
// Check if running as SEA
let isSEA = false;
try {
const { sea } = require('node:sea');
isSEA = sea.isSea();
if (isSEA) {
console.log('Running in SEA mode');
// Set process title
process.title = '${config.displayName || config.appName}';
}
} catch (err) {
// SEA module not available, running in regular Node.js
console.log('Running in development mode');
}
// Set working directory to executable directory
try {
const path = require('path');
const execDir = path.dirname(process.execPath);
process.chdir(execDir);
} catch (err) {
console.warn('Could not change working directory:', err.message);
}
// Load the main application
try {
require('./${mainScript}');
} catch (error) {
console.error('❌ Failed to start application:', error.message);
console.error(error.stack);
process.exit(1);
}
`;
const entryPath = path.join(packageDir, 'sea-entry.js');
await fs.writeFile(entryPath, entryContent);
// Step 3: Bundle with NCC
console.log('📦 Bundling application with NCC...');
const bundleCommand = `npx ncc build sea-entry.js -o sea-dist --minify --no-source-map-register`;
executeCommand(bundleCommand, { cwd: packageDir });
const bundledPath = path.join(packageDir, 'sea-dist', 'index.js');
if (!await fs.pathExists(bundledPath)) {
throw new Error('NCC bundling failed - bundled file not found');
}
// Step 4: Create SEA configuration
const seaConfig = {
main: 'sea-dist/index.js',
output: 'sea-prep.blob',
disableExperimentalSEAWarning: true,
useSnapshot: false,
useCodeCache: true
};
const seaConfigPath = path.join(packageDir, 'sea-config.json');
await fs.writeJson(seaConfigPath, seaConfig, { spaces: 2 });
// Step 5: Generate SEA blob
console.log('🗜️ Generating SEA blob...');
const originalCwd = process.cwd();
process.chdir(packageDir);
try {
executeCommand('node --experimental-sea-config sea-config.json');
const blobPath = path.join(packageDir, 'sea-prep.blob');
if (!await fs.pathExists(blobPath)) {
throw new Error('SEA blob generation failed');
}
console.log('✅ SEA blob created successfully');
// Step 6: Copy Node.js executable
console.log('📄 Creating executable base...');
const nodeBinaryPath = await getNodeBinaryPath();
await fs.copyFile(nodeBinaryPath, executablePath);
if (!await fs.pathExists(executablePath)) {
throw new Error('Failed to create executable base');
}
// Step 7: Remove signature (Windows)
console.log('🔓 Removing executable signature...');
try {
const { findWindowsSDKTools } = require('./utils');
const tools = findWindowsSDKTools();
const removeSignCommand = `"${tools.signtool}" remove /s "${executableName}"`;
executeCommand(removeSignCommand);
} catch (sigError) {
console.warn('⚠️ Could not remove signature (this is usually fine)');
}
// Step 8: Inject blob with postject
console.log('💉 Injecting SEA blob into executable...');
const postjectCommand = `npx postject "${executableName}" NODE_SEA_BLOB sea-prep.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2`;
executeCommand(postjectCommand);
// Step 9: Sign the executable
await signSEAExecutable(executablePath, config);
// Step 10: Cleanup temporary files
await fs.remove(entryPath);
await fs.remove(path.join(packageDir, 'sea-dist'));
await fs.remove(seaConfigPath);
await fs.remove(blobPath);
// Update config
config.executable = executableName;
delete config.executableArgs;
console.log(`✅ SEA executable created: ${executableName}`);
return true;
} finally {
process.chdir(originalCwd);
}
} catch (error) {
console.warn(`⚠️ SEA creation failed: ${error.message}`);
console.log('Falling back to PKG...');
return false;
}
}
/**
* Gets the Node.js binary path for the current platform
* @returns {Promise<string>} Path to Node.js binary
*/
async function getNodeBinaryPath() {
return process.execPath;
}
/**
* Signs the SEA executable
* @param {string} executablePath - Path to executable
* @param {Object} config - Configuration object
*/
async function signSEAExecutable(executablePath, config) {
try {
const { determineSigningMethod } = require('./certificates');
const { findWindowsSDKTools } = require('./utils');
const signingMethod = await determineSigningMethod(config);
if (!signingMethod) {
console.log('No signing configuration found, skipping...');
return;
}
const tools = findWindowsSDKTools();
let signCommand;
if (signingMethod.method === 'pfx') {
const { pfxPath, password, timestampUrl } = signingMethod.parameters;
signCommand = `"${tools.signtool}" sign /f "${pfxPath}" /p "${password}" /tr "${timestampUrl}" /td SHA256 /fd SHA256 "${executablePath}"`;
} else if (signingMethod.method === 'store') {
const { thumbprint, store, timestampUrl } = signingMethod.parameters;
const storeLocation = store.toLowerCase() === 'localmachine' ? '/sm' : '';
signCommand = `"${tools.signtool}" sign /sha1 "${thumbprint}" /s "My" ${storeLocation} /fd SHA256 "${executablePath}"`;
}
if (signCommand) {
executeCommand(signCommand);
console.log('✅ SEA executable signed successfully');
}
} catch (error) {
console.warn(`Warning: Could not sign SEA executable: ${error.message}`);
}
}
/**
* Creates a PKG fallback executable
* @param {string} packageDir - Package directory path
* @param {Object} config - Configuration object
* @param {Object} packageJson - package.json content
*/
async function createFallbackLauncher(packageDir, config, packageJson) {
try {
console.log('📦 Creating PKG fallback executable...');
const executableName = `${config.appName || 'app'}.exe`;
const executablePath = path.join(packageDir, executableName);
const mainScript = packageJson.main || 'index.js';
// Create launcher script with proper directory handling
const launcherContent = `#!/usr/bin/env node
// PKG Launcher for ${config.displayName || config.appName}
console.log('🚀 Starting ${config.displayName || config.appName}...');
// Set working directory - handle PKG snapshot case
try {
if (__dirname.includes('snapshot')) {
// Running from PKG, use the executable's directory
const path = require('path');
const execDir = path.dirname(process.execPath);
process.chdir(execDir);
} else {
// Running in development mode
process.chdir(__dirname);
}
} catch (err) {
console.warn('Could not change working directory:', err.message);
}
// Load the main application
try {
require('./${mainScript}');
} catch (error) {
console.error('❌ Failed to start application:', error.message);
console.error(error.stack);
process.exit(1);
}
`;
const launcherScript = path.join(packageDir, 'launcher.js');
await fs.writeFile(launcherScript, launcherContent);
// Create PKG configuration
const pkgConfig = {
name: config.appName || 'app',
version: packageJson.version || '1.0.0',
main: 'launcher.js',
bin: 'launcher.js',
pkg: {
scripts: [mainScript],
targets: ['node18-win-x64'],
outputPath: '.'
},
dependencies: packageJson.dependencies || {}
};
const pkgConfigPath = path.join(packageDir, 'pkg-config.json');
await fs.writeJson(pkgConfigPath, pkgConfig, { spaces: 2 });
// Create executable with PKG
const finalExeName = path.basename(executablePath, '.exe');
const pkgCommand = `npx pkg launcher.js --target node18-win-x64 --output "${finalExeName}" --config pkg-config.json`;
executeCommand(pkgCommand, { cwd: packageDir });
// Verify executable was created
if (await fs.pathExists(executablePath)) {
console.log(`✅ Created executable with PKG: ${executableName}`);
// Clean up temporary files
await fs.remove(launcherScript);
await fs.remove(pkgConfigPath);
// Update config
config.executable = executableName;
delete config.executableArgs;
return true;
}
} catch (pkgError) {
console.warn(`⚠️ PKG fallback failed: ${pkgError.message}`);
return false;
}
}
module.exports = {
createSingleExecutableApp,
createFallbackLauncher
};