UNPKG

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

800 lines (687 loc) 29.7 kB
const path = require('path'); const fs = require('fs-extra'); const { executeCommand, findWindowsSDKTools, formatFileSize, getLatestNodeVersion } = require('./utils'); const { determineSigningMethod } = require('./certificates'); const { generateManifest, createDefaultAssets, generateResourceConfig } = require('./manifest'); const { CONSTANTS, MSIXError, SigningError } = require('./constants'); /** * Creates an MSIX package * @param {string} packageDir - Directory containing package contents * @param {string} outputPath - Output MSIX file path * @returns {string} Path to created MSIX file */ async function createMsixPackage(packageDir, outputPath) { try { console.log('Creating MSIX package...'); const tools = findWindowsSDKTools(); // Ensure output directory exists const outputDir = path.dirname(outputPath); await fs.ensureDir(outputDir); // Create the MSIX package using makeappx const command = `"${tools.makeappx}" pack /d "${packageDir}" /p "${outputPath}" /overwrite`; console.log('Running makeappx...'); executeCommand(command); // Verify the package was created if (!(await fs.pathExists(outputPath))) { throw new MSIXError('MSIX package was not created successfully'); } const stats = await fs.stat(outputPath); console.log(`MSIX package created: ${outputPath} (${formatFileSize(stats.size)})`); return outputPath; } catch (error) { throw new MSIXError(`Failed to create MSIX package: ${error.message}`); } } /** * Signs an MSIX package * @param {string} packagePath - Path to MSIX package to sign * @param {Object} signingConfig - Signing configuration * @returns {boolean} True if signing was successful */ async function signMsixPackage(packagePath, signingConfig) { try { const signingMethod = await determineSigningMethod(signingConfig); if (!signingMethod) { console.log('Signing disabled, skipping...'); return false; } console.log(`Signing package using ${signingMethod.method} method...`); const tools = findWindowsSDKTools(); let signCommand; if (signingMethod.method === 'pfx') { // Sign with PFX file const { pfxPath, password, timestampUrl } = signingMethod.parameters; signCommand = `"${tools.signtool}" sign /f "${pfxPath}" /p "${password}" /tr "${timestampUrl}" /td SHA256 /fd SHA256 "${packagePath}"`; } else if (signingMethod.method === 'store') { // Sign with certificate from store const { thumbprint, store, timestampUrl } = signingMethod.parameters; const storeLocation = store.toLowerCase() === 'localmachine' ? '/sm' : ''; console.log(`Using certificate from ${store} store with thumbprint: ${thumbprint}`); // For test certificates, try the most basic signing approach first if (timestampUrl && timestampUrl.length > 0) { try { signCommand = `"${tools.signtool}" sign /sha1 "${thumbprint}" /s "My" ${storeLocation} /fd SHA256 "${packagePath}"`; console.log('Attempting basic signing without timestamp first...'); executeCommand(signCommand); console.log('Basic signing successful, package signed without timestamp'); return true; } catch (basicError) { console.log('Basic signing failed, trying with timestamp...'); signCommand = `"${tools.signtool}" sign /sha1 "${thumbprint}" /s "My" ${storeLocation} /tr "${timestampUrl}" /td SHA256 /fd SHA256 "${packagePath}"`; } } else { console.log('Signing without timestamp server...'); signCommand = `"${tools.signtool}" sign /sha1 "${thumbprint}" /s "My" ${storeLocation} /fd SHA256 "${packagePath}"`; } } else { throw new SigningError(`Unknown signing method: ${signingMethod.method}`); } console.log('Running signtool...'); executeCommand(signCommand); console.log('Package signed successfully'); return true; } catch (error) { throw new SigningError(`Failed to sign MSIX package: ${error.message}`); } } /** * Prepares the package directory structure with enhanced workflow * This implements the proper Node.js app packaging workflow: * 1. Copy source code to temp directory * 2. Build the application (install deps, run build scripts) * 3. Create executable structure * 4. Create proper MSIX directory structure * @param {Object} config - Configuration object * @param {string} tempDir - Temporary directory for package preparation * @param {Object} packageJson - package.json content * @returns {string} Path to prepared package directory */ async function preparePackageDirectory(config, tempDir, packageJson) { try { console.log('🏗️ Preparing package directory structure...'); // Step 1: Create temporary directories for the enhanced workflow const sourceDir = path.join(tempDir, 'source'); const buildDir = path.join(tempDir, 'build'); const packageDir = path.join(tempDir, 'package'); const assetsDir = path.join(packageDir, 'Assets'); await fs.ensureDir(sourceDir); await fs.ensureDir(buildDir); await fs.ensureDir(packageDir); await fs.ensureDir(assetsDir); // Step 2: Copy source code to temporary directory console.log('📁 Copying source code to temporary directory...'); const { copyFiles } = require('./utils'); await copyFiles(config.inputPath, sourceDir, true); // Step 3: Build the application (install dependencies and run build scripts) console.log('🔧 Building the application...'); await buildApplication(sourceDir, buildDir, packageJson, config); // Step 4: Create executable structure console.log('⚙️ Creating executable structure...'); await createExecutableStructure(buildDir, packageDir, config, packageJson); // Step 5: Generate MSIX directory structure console.log('📦 Setting up MSIX directory structure...'); await setupMsixStructure(packageDir, assetsDir, config, packageJson); console.log('✅ Package directory prepared successfully'); return packageDir; } catch (error) { throw new MSIXError(`Failed to prepare package directory: ${error.message}`); } } /** * Builds the Node.js application in the build directory * @param {string} sourceDir - Source directory path * @param {string} buildDir - Build directory path * @param {Object} packageJson - package.json content * @param {Object} config - Configuration object */ async function buildApplication(sourceDir, buildDir, packageJson, config) { try { // Copy source files to build directory with filtering console.log('📋 Copying source files for build...'); await fs.copy(sourceDir, buildDir, { overwrite: true, errorOnExist: false, filter: (src) => { // Skip node_modules, build artifacts, and temporary files const relativePath = path.relative(sourceDir, src); const excludePatterns = [ 'node_modules', '.git', 'dist', 'build', '.nyc_output', 'coverage', '.vscode', '.idea', '*.log', '.env' ]; return !excludePatterns.some(pattern => relativePath.includes(pattern) || relativePath.endsWith(pattern.replace('*', '')) ); } }); // Install dependencies (include dev dependencies if build script exists or configured) const packageJsonPath = path.join(buildDir, 'package.json'); if (await fs.pathExists(packageJsonPath)) { const needsDevDeps = config.installDevDeps && ((packageJson.scripts && packageJson.scripts.build) || config.installDevDeps === true); if (needsDevDeps) { console.log('📦 Installing all dependencies (including dev dependencies for build)...'); await installNodeDependencies(buildDir, false); // false = don't production-only } else { console.log('📦 Installing production dependencies...'); await installNodeDependencies(buildDir, true); // true = production-only } } // Run build script if defined in package.json and not skipped if (!config.skipBuild && packageJson.scripts && packageJson.scripts.build) { console.log('🔨 Running build script...'); const originalCwd = process.cwd(); try { process.chdir(buildDir); executeCommand('npm run build'); } catch (buildError) { console.warn(`⚠️ Build script failed: ${buildError.message}`); // Check if this is a TypeScript build issue if (buildError.message.includes('tsc') || buildError.message.includes('TypeScript')) { console.log('🔧 Detected TypeScript build failure. Attempting to install TypeScript...'); try { // Try to install TypeScript locally executeCommand('npm install typescript --save-dev --silent'); console.log('📦 TypeScript installed. Retrying build...'); executeCommand('npm run build'); console.log('✅ Build succeeded after installing TypeScript'); } catch (retryError) { console.warn(`⚠️ Build still failed after installing TypeScript: ${retryError.message}`); console.log('📝 Continuing without build step. You may need to manually build your application before packaging.'); } } else { console.warn('📝 Continuing without build step. Ensure your application is pre-built if needed.'); } } finally { process.chdir(originalCwd); } } else if (config.skipBuild) { console.log('⏭️ Skipping build step as requested in configuration'); } console.log('✅ Application built successfully'); } catch (error) { throw new MSIXError(`Failed to build application: ${error.message}`); } } /** * Creates the executable structure for the MSIX package * @param {string} buildDir - Build directory path * @param {string} packageDir - Package directory path * @param {Object} config - Configuration object * @param {Object} packageJson - package.json content */ async function createExecutableStructure(buildDir, packageDir, config, packageJson) { try { // Copy built application files to package directory console.log('📁 Copying built application files...'); await fs.copy(buildDir, packageDir, { overwrite: true, errorOnExist: false, filter: (src) => { // Skip development files const basename = path.basename(src); const excludeFiles = [ '.gitignore', '.env.example', 'README.md', 'CHANGELOG.md', 'LICENSE', 'tsconfig.json', 'jest.config.js', 'webpack.config.js' ]; return !excludeFiles.includes(basename); } }); // Ensure Node.js runtime is available console.log('🟢 Ensuring Node.js runtime...'); await ensureNodeRuntime(packageDir, config); // Create startup script if the main executable is node.exe if (config.executable === 'node.exe' || config.executable.endsWith('node.exe')) { await createNodeStartupScript(packageDir, packageJson, config); } // Try to create SEA (Single Executable Application) first const { createSingleExecutableApp, createFallbackLauncher } = require('./sea-handler'); const seaSuccess = await createSingleExecutableApp(packageDir, config, packageJson); // Create fallback launcher if SEA creation failed if (!seaSuccess) { console.log('📄 Creating fallback Node.js launcher...'); await createFallbackLauncher(packageDir, config, packageJson); } console.log('✅ Executable structure created successfully'); } catch (error) { throw new MSIXError(`Failed to create executable structure: ${error.message}`); } } /** * Sets up the MSIX directory structure with manifests and assets * @param {string} packageDir - Package directory path * @param {string} assetsDir - Assets directory path * @param {Object} config - Configuration object * @param {Object} packageJson - package.json content */ async function setupMsixStructure(packageDir, assetsDir, config, packageJson) { try { // Generate AppxManifest.xml console.log('📄 Generating AppxManifest.xml...'); const manifestContent = generateManifest(config, packageJson); await fs.writeFile(path.join(packageDir, 'AppxManifest.xml'), manifestContent); // Create package assets console.log('🎨 Creating package assets...'); await createDefaultAssets(assetsDir, config.icon); // Create MCP Server configuration console.log('🔧 Creating MCP Server configuration...'); await createMcpServerConfig(assetsDir, config); // Generate resource configuration console.log('⚙️ Generating resource configuration...'); const resourceConfig = generateResourceConfig(config); await fs.writeFile(path.join(packageDir, 'msix-resource-config.json'), resourceConfig); // Create registry entries if needed await createRegistryEntries(packageDir, config, packageJson); console.log('✅ MSIX structure setup completed'); } catch (error) { throw new MSIXError(`Failed to setup MSIX structure: ${error.message}`); } } /** * Creates a Node.js startup script * @param {string} packageDir - Package directory path * @param {Object} packageJson - package.json content * @param {Object} config - Configuration object */ async function createNodeStartupScript(packageDir, packageJson, config) { try { const mainScript = packageJson.main || 'index.js'; const startScript = packageJson.scripts && packageJson.scripts.start; // Create batch file to start the Node.js application let batchContent; if (startScript && !startScript.includes('node ')) { // Use the start script if it doesn't already include 'node' batchContent = `@echo off title ${config.displayName || config.appName} cd /d "%~dp0" echo Starting ${config.displayName || config.appName}... npm start if errorlevel 1 ( echo. echo Failed to start the application. echo Make sure Node.js is installed on this system. echo. pause exit /b 1 ) `; } else { // Create direct node execution with configurable args const nodeArgs = config.executableArgs || mainScript; batchContent = `@echo off title ${config.displayName || config.appName} cd /d "%~dp0" echo Starting ${config.displayName || config.appName}... node ${nodeArgs} %* if errorlevel 1 ( echo. echo Failed to start the application. echo Make sure Node.js is installed on this system. echo. pause exit /b 1 ) `; } const batchPath = path.join(packageDir, 'start.bat'); await fs.writeFile(batchPath, batchContent); // Also create a silent launcher for background services const silentBatchContent = `@echo off cd /d "%~dp0" start "" /min cmd /c "node "${mainScript}" %*" `; const silentBatchPath = path.join(packageDir, 'start-silent.bat'); await fs.writeFile(silentBatchPath, silentBatchContent); console.log('📄 Created Node.js startup scripts (interactive and silent)'); } catch (error) { console.warn(`Warning: Could not create startup script: ${error.message}`); } } /** * Creates registry entries for the application if needed * @param {string} packageDir - Package directory path * @param {Object} config - Configuration object * @param {Object} packageJson - package.json content */ async function createRegistryEntries(packageDir, config, packageJson) { try { // Create a registry file for application registration const regContent = `Windows Registry Editor Version 5.00 [HKEY_CURRENT_USER\\Software\\Classes\\Applications\\${config.executable}] "FriendlyAppName"="${config.displayName || config.appName}" [HKEY_CURRENT_USER\\Software\\Classes\\Applications\\${config.executable}\\shell] [HKEY_CURRENT_USER\\Software\\Classes\\Applications\\${config.executable}\\shell\\open] [HKEY_CURRENT_USER\\Software\\Classes\\Applications\\${config.executable}\\shell\\open\\command] @="\\"${config.executable}\\" \\"%1\\"" `; const regPath = path.join(packageDir, 'app-registration.reg'); await fs.writeFile(regPath, regContent); console.log('📋 Created registry entries'); } catch (error) { console.warn(`Warning: Could not create registry entries: ${error.message}`); } } /** * Creates MCP Server configuration file in the Assets directory * @param {string} assetsDir - Assets directory path * @param {Object} config - Configuration object */ async function createMcpServerConfig(assetsDir, config) { try { // Generate the executable name from config let executableName; if (config.executable && config.executable.endsWith('.exe')) { // Use the configured executable name executableName = config.executable; } else { // Generate executable name from app name const appName = config.appName || config.displayName || 'app'; executableName = `${appName.replace(/[^a-zA-Z0-9.-]/g, '_')}.exe`; } // Create MCP server configuration const mcpConfig = { "version": 1, "mcpServers": [ { "name": `${config.packageName || config.appName}MCPServer`, "type": "stdio", "command": executableName } ] }; const configPath = path.join(assetsDir, 'mcpServerConfig.json'); await fs.writeFile(configPath, JSON.stringify(mcpConfig, null, 2)); console.log('📄 Created MCP server configuration: mcpServerConfig.json'); } catch (error) { console.warn(`Warning: Could not create MCP server configuration: ${error.message}`); } } /** * Installs Node.js dependencies in the package directory * @param {string} packageDir - Package directory path * @param {boolean} productionOnly - Whether to install only production dependencies */ async function installNodeDependencies(packageDir, productionOnly = true) { try { const originalCwd = process.cwd(); process.chdir(packageDir); try { // Use npm ci for faster, reliable, reproducible builds const productionFlag = productionOnly ? '--production' : ''; executeCommand(`npm ci ${productionFlag} --silent`); } catch (error) { // Fallback to npm install console.log('npm ci failed, falling back to npm install...'); const productionFlag = productionOnly ? '--production' : ''; executeCommand(`npm install ${productionFlag} --silent`); } process.chdir(originalCwd); console.log('Dependencies installed successfully'); } catch (error) { throw new MSIXError(`Failed to install Node.js dependencies: ${error.message}`); } } /** * Ensures Node.js runtime is available in the package * @param {string} packageDir - Package directory path * @param {Object} config - Configuration object */ async function ensureNodeRuntime(packageDir, config) { try { const nodeExecutablePath = path.join(packageDir, 'node.exe'); // Always remove existing node.exe to ensure fresh copy if (await fs.pathExists(nodeExecutablePath)) { console.log('🗑️ Removing existing Node.js runtime to ensure fresh copy...'); await fs.remove(nodeExecutablePath); } // Check if we have a bundled Node.js runtime const bundledNodePath = path.join(__dirname, '..', 'runtime', 'nodejs', 'node.exe'); if (await fs.pathExists(bundledNodePath)) { console.log('Copying bundled Node.js runtime...'); await fs.copy(bundledNodePath, nodeExecutablePath); return; } // Try to copy system Node.js executable using fresh binary detection try { console.log('🔍 Attempting to get fresh Node.js binary...'); const { getFreshNodeBinary } = require('./sea-handler'); // Use the enhanced fresh binary detection that avoids SEA markers const freshNodePath = await getFreshNodeBinary(); console.log(`🔍 Fresh binary search result: ${freshNodePath}`); if (freshNodePath && await fs.pathExists(freshNodePath)) { console.log(`Copying fresh Node.js runtime from: ${freshNodePath}`); // Only copy the node.exe file, not entire directories await fs.copyFile(freshNodePath, nodeExecutablePath); // Verify the copy worked if (await fs.pathExists(nodeExecutablePath)) { console.log('✅ Fresh Node.js runtime copied successfully'); // If config is using node.exe, also create a startup script if (config.executable === 'node.exe') { await createNodeStartupWrapper(packageDir, config); } return; } } } catch (error) { console.warn(`Warning: Could not copy fresh Node.js: ${error.message}`); console.log(`Error details: ${error.stack}`); // Fallback to basic system node lookup try { console.log('🔙 Falling back to basic system Node.js lookup...'); const { executeCommand } = require('./utils'); const wherePath = process.platform === 'win32' ? 'where' : 'which'; const systemNodePath = executeCommand(`${wherePath} node`, { silent: true }).trim().split('\n')[0]; if (systemNodePath && await fs.pathExists(systemNodePath)) { console.log(`Copying system Node.js runtime from: ${systemNodePath}`); // Only copy the node.exe file, not entire directories await fs.copyFile(systemNodePath, nodeExecutablePath); // Verify the copy worked if (await fs.pathExists(nodeExecutablePath)) { console.log('✅ Node.js runtime copied successfully'); // If config is using node.exe, also create a startup script if (config.executable === 'node.exe') { await createNodeStartupWrapper(packageDir, config); } return; } } } catch (fallbackError) { console.warn(`Warning: Fallback Node.js copy also failed: ${fallbackError.message}`); } } // If we can't find Node.js, try to download it await downloadNodejs(packageDir, config.architecture || 'x64'); } catch (error) { console.warn(`Warning: Could not ensure Node.js runtime: ${error.message}`); console.warn('📝 The application may not work without Node.js installed on the target system.'); } } /** * Creates a Node.js startup wrapper when using node.exe directly * @param {string} packageDir - Package directory path * @param {Object} config - Configuration object */ async function createNodeStartupWrapper(packageDir, config) { try { // Create a launcher.js that handles the startup logic const launcherContent = `const { spawn } = require('child_process'); const path = require('path'); // Determine the main script to run const mainScript = 'app.js'; // Default for our sample app console.log('Starting ${config.displayName || config.appName}...'); // Launch the main application const child = spawn(process.execPath, [mainScript], { stdio: 'inherit', cwd: __dirname }); child.on('error', (err) => { console.error('Failed to start application:', err.message); console.log('Press any key to exit...'); process.stdin.once('data', () => process.exit(1)); }); child.on('exit', (code) => { if (code !== 0) { console.log('\\nApplication exited with code:', code); console.log('Press any key to exit...'); process.stdin.once('data', () => process.exit(code)); } else { process.exit(0); } }); // Handle termination gracefully process.on('SIGINT', () => { child.kill('SIGINT'); }); process.on('SIGTERM', () => { child.kill('SIGTERM'); }); `; const launcherPath = path.join(packageDir, 'launcher.js'); await fs.writeFile(launcherPath, launcherContent); console.log('📄 Created Node.js startup wrapper'); } catch (error) { console.warn(`Warning: Could not create Node.js startup wrapper: ${error.message}`); } } /** * Downloads Node.js runtime for the specified architecture * @param {string} packageDir - Package directory path * @param {string} architecture - Target architecture (x64, x86) */ async function downloadNodejs(packageDir, architecture) { try { console.log(`📥 Attempting to download Node.js runtime for ${architecture}...`); // Get the latest Node.js version dynamically const nodeVersion = await getLatestNodeVersion(); console.log(`🎯 Using Node.js version: ${nodeVersion}`); const https = require('https'); const archMap = { x64: 'x64', x86: 'x86', arm64: 'arm64' }; const nodeArch = archMap[architecture] || 'x64'; const downloadUrl = `https://nodejs.org/dist/${nodeVersion}/win-${nodeArch}/node.exe`; const nodeExecutablePath = path.join(packageDir, 'node.exe'); await new Promise((resolve, reject) => { const file = fs.createWriteStream(nodeExecutablePath); https.get(downloadUrl, (response) => { if (response.statusCode === 200) { response.pipe(file); file.on('finish', () => { file.close(); console.log('✅ Node.js runtime downloaded successfully'); resolve(); }); } else { reject(new Error(`Failed to download Node.js: HTTP ${response.statusCode}`)); } }).on('error', (err) => { fs.unlink(nodeExecutablePath, () => {}); // Clean up on error reject(err); }); }); } catch (error) { console.warn(`Warning: Could not download Node.js runtime: ${error.message}`); console.warn('💡 Consider manually placing node.exe in your input directory.'); } } /** * Validates the prepared package * @param {string} packageDir - Package directory path * @returns {Object} Validation results */ async function validatePackage(packageDir) { const issues = []; const warnings = []; try { // Check for required files const requiredFiles = ['AppxManifest.xml']; for (const file of requiredFiles) { const filePath = path.join(packageDir, file); if (!(await fs.pathExists(filePath))) { issues.push(`Missing required file: ${file}`); } } // Check for executable const manifestPath = path.join(packageDir, 'AppxManifest.xml'); if (await fs.pathExists(manifestPath)) { const manifestContent = await fs.readFile(manifestPath, 'utf8'); const executableMatch = manifestContent.match(/Executable="([^"]+)"/); if (executableMatch) { const executablePath = path.join(packageDir, executableMatch[1]); if (!(await fs.pathExists(executablePath))) { issues.push(`Executable not found: ${executableMatch[1]}`); } } } // Check for assets const assetsDir = path.join(packageDir, 'Assets'); const requiredAssets = [ 'Square44x44Logo.png', 'Square150x150Logo.png', 'StoreLogo.png' ]; for (const asset of requiredAssets) { const assetPath = path.join(assetsDir, asset); if (!(await fs.pathExists(assetPath))) { warnings.push(`Missing asset: ${asset}`); } } // Check package size const packageSize = await getDirectorySize(packageDir); if (packageSize > CONSTANTS.MAX_PACKAGE_SIZE) { warnings.push(`Package size (${formatFileSize(packageSize)}) exceeds recommended maximum (${formatFileSize(CONSTANTS.MAX_PACKAGE_SIZE)})`); } return { isValid: issues.length === 0, issues, warnings, packageSize: formatFileSize(packageSize) }; } catch (error) { return { isValid: false, issues: [`Validation failed: ${error.message}`], warnings: [], packageSize: 'Unknown' }; } } /** * Gets the total size of a directory * @param {string} dirPath - Directory path * @returns {number} Size in bytes */ async function getDirectorySize(dirPath) { let totalSize = 0; async function calculateSize(currentPath) { const stats = await fs.stat(currentPath); if (stats.isFile()) { totalSize += stats.size; } else if (stats.isDirectory()) { const items = await fs.readdir(currentPath); for (const item of items) { await calculateSize(path.join(currentPath, item)); } } } await calculateSize(dirPath); return totalSize; } module.exports = { createMsixPackage, signMsixPackage, preparePackageDirectory, validatePackage, installNodeDependencies, ensureNodeRuntime };