diff --git a/scripts/build-mcpb.cjs b/scripts/build-mcpb.cjs index f1dc3a40..bc404ddf 100755 --- a/scripts/build-mcpb.cjs +++ b/scripts/build-mcpb.cjs @@ -4,11 +4,12 @@ * Build script for creating Desktop Commander MCPB bundle * * This script: - * 1. Builds the TypeScript project - * 2. Creates a bundle directory structure - * 3. Generates a proper MCPB manifest.json - * 4. Copies the built server and dependencies - * 5. Uses mcpb CLI to create the final .mcpb bundle + * 1. Uses esbuild to create a single-file ESM bundle + * 2. Post-processes the bundle to fix esbuild ESM compatibility issues + * 3. Creates a bundle directory structure + * 4. Generates a proper MCPB manifest.json + * 5. Copies native dependencies that can't be bundled + * 6. Uses mcpb CLI to create the final .mcpb bundle */ const fs = require('fs'); @@ -31,30 +32,109 @@ try { process.exit(1); } -// Step 1: Build the TypeScript project -console.log('๐Ÿ“ฆ Building TypeScript project...'); +// Step 1: Clean and create bundle directory +console.log('๐Ÿงน Cleaning bundle directory...'); +if (fs.existsSync(BUNDLE_DIR)) { + fs.rmSync(BUNDLE_DIR, { recursive: true }); +} +fs.mkdirSync(BUNDLE_DIR, { recursive: true }); + +// Step 2: Bundle with esbuild into a single ESM file +// Native modules (sharp, ripgrep) and CJS-only packages (pdf2md, pdf-lib) are kept external +console.log('๐Ÿ“ฆ Bundling with esbuild...'); try { - execSync('npm run build', { cwd: PROJECT_ROOT, stdio: 'inherit' }); - console.log('โœ… TypeScript build completed'); + const distDir = path.join(BUNDLE_DIR, 'dist'); + fs.mkdirSync(distDir, { recursive: true }); + + const externals = ['sharp', '@vscode/ripgrep', 'vscode', '@opendocsg/pdf2md', 'pdf-lib']; + const externalFlags = externals.map(e => `--external:${e}`).join(' '); + execSync(`npx esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=mcpb-bundle/dist/index.js ${externalFlags}`, { cwd: PROJECT_ROOT, stdio: 'inherit' }); + console.log('โœ… esbuild bundling completed'); + + // Step 2b: Post-process the bundle to fix esbuild ESM compatibility issues + console.log('๐Ÿ”ง Post-processing bundle for ESM compatibility...'); + const bundlePath = path.join(BUNDLE_DIR, 'dist', 'index.js'); + let bundleContent = fs.readFileSync(bundlePath, 'utf8'); + + // Fix 1: Inject ESM compatibility shims + // esbuild's ESM format wraps CJS require() calls in a shim that throws at runtime. + // We inject createRequire so Node.js built-ins (fs, path, child_process, etc.) work. + // Also inject __dirname/__filename which CJS modules expect but ESM doesn't provide. + const shebanLine = '#!/usr/bin/env node'; + const esmShims = [ + shebanLine, + 'import { createRequire as __bundleCreateRequire } from "module";', + 'import { fileURLToPath as __bundleFileURLToPath } from "url";', + 'import { dirname as __bundleDirname } from "path";', + 'var require = __bundleCreateRequire(import.meta.url);', + 'var __filename = __bundleFileURLToPath(import.meta.url);', + 'var __dirname = __bundleDirname(__filename);', + ].join('\n'); + if (bundleContent.startsWith(shebanLine)) { + bundleContent = bundleContent.replace(shebanLine, esmShims); + } else { + bundleContent = esmShims.replace(shebanLine + '\n', '') + '\n' + bundleContent; + } + console.log(' โœ… Injected ESM compatibility shims (createRequire, __dirname, __filename)'); + + // Fix 2: Propagate async to __esm callbacks that contain await + // esbuild bug: when module A has top-level await and module B imports from A, + // esbuild correctly makes A's __esm callback async but fails to make B's async too. + // This causes "SyntaxError: Unexpected reserved word" for await in non-async functions. + // Fix: scan all __esm blocks and add async to any that contain await in their body. + let fixCount = 0; + const esmBlockRegex = /var (\w+) = __esm\(\{\n(\s+)(async )?"([^"]+)"\(\)/g; + const fixes = []; + let match; + while ((match = esmBlockRegex.exec(bundleContent)) !== null) { + if (match[3]) continue; // Already async + const modName = match[4]; + const blockStart = match.index; + const nextInit = bundleContent.indexOf('\nvar init_', blockStart + match[0].length); + const blockEnd = nextInit !== -1 ? nextInit : blockStart + 10000; + const blockBody = bundleContent.slice(blockStart, blockEnd); + if (blockBody.includes('await ')) { + fixes.push({ + position: match.index + match[0].indexOf(`"${modName}"()`), + oldText: `"${modName}"()`, + newText: `async "${modName}"()` + }); + } + } + for (let i = fixes.length - 1; i >= 0; i--) { + const fix = fixes[i]; + bundleContent = bundleContent.slice(0, fix.position) + fix.newText + bundleContent.slice(fix.position + fix.oldText.length); + fixCount++; + } + if (fixCount > 0) { + console.log(` โœ… Fixed ${fixCount} non-async __esm callbacks containing await`); + } + + // Fix 3: Remove the isMainModule auto-start block from device.ts + // device.ts has a module-level guard: if (import.meta.url === process.argv[1]) { device.start() } + // In the single-file bundle, import.meta.url always matches process.argv[1], so this + // incorrectly starts the remote device on every run, outputting debug messages to stdout + // which breaks Claude Desktop's JSON-RPC transport. + const isMainModulePattern = /var isMainModule = process\.argv\[1\][\s\S]*?if \(isMainModule\) \{[\s\S]*?\n\}/; + if (bundleContent.match(isMainModulePattern)) { + bundleContent = bundleContent.replace(isMainModulePattern, '// [MCPB] Removed isMainModule auto-start block (not applicable in bundle context)'); + console.log(' โœ… Removed isMainModule auto-start block from device.ts'); + } + + fs.writeFileSync(bundlePath, bundleContent); + console.log('โœ… Post-processing completed'); } catch (error) { - console.error('โŒ TypeScript build failed:', error.message); + console.error('โŒ esbuild bundling/post-processing failed:', error.message); process.exit(1); } -// Step 2: Clean and create bundle directory -if (fs.existsSync(BUNDLE_DIR)) { - fs.rmSync(BUNDLE_DIR, { recursive: true }); -}fs.mkdirSync(BUNDLE_DIR, { recursive: true }); - // Step 3: Read package.json for version and metadata const packageJson = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8')); // Step 4: Load and process manifest template console.log('๐Ÿ“ Processing manifest template...'); - const manifestTemplatePath = path.join(PROJECT_ROOT, 'manifest.template.json'); console.log(`๐Ÿ“„ Using manifest: manifest.template.json`); - let manifestTemplate; try { manifestTemplate = fs.readFileSync(manifestTemplatePath, 'utf8'); @@ -62,11 +142,7 @@ try { console.error('โŒ Failed to read manifest template:', manifestTemplatePath); process.exit(1); } - -// Replace template variables const manifestContent = manifestTemplate.replace('{{VERSION}}', packageJson.version); - -// Parse and validate the resulting manifest let manifest; try { manifest = JSON.parse(manifestContent); @@ -74,30 +150,18 @@ try { console.error('โŒ Invalid JSON in manifest template:', error.message); process.exit(1); } - -// Write manifest fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2)); console.log('โœ… Created manifest.json'); -// Step 5: Copy necessary files -const filesToCopy = [ - 'dist', - 'package.json', - 'README.md', - 'LICENSE', - 'PRIVACY.md', - 'icon.png' -]; +// Step 5: Copy necessary files (dist is generated by esbuild, not copied) +const filesToCopy = ['package.json', 'README.md', 'LICENSE', 'PRIVACY.md', 'icon.png']; filesToCopy.forEach(file => { const srcPath = path.join(PROJECT_ROOT, file); const destPath = path.join(BUNDLE_DIR, file); - if (fs.existsSync(srcPath)) { if (fs.statSync(srcPath).isDirectory()) { - // Copy directory recursively fs.cpSync(srcPath, destPath, { recursive: true }); } else { - // Copy file fs.copyFileSync(srcPath, destPath); } console.log(`โœ… Copied ${file}`); @@ -106,22 +170,26 @@ filesToCopy.forEach(file => { } }); -// Step 6: Create package.json in bundle with production dependencies from main package.json -// This ensures MCPB bundle always has the same dependencies as the npm package +// Step 6: Create bundle package.json with only native/CJS-only dependencies +// Everything else is bundled by esbuild into the single dist/index.js file const bundlePackageJson = { name: manifest.name, version: manifest.version, description: manifest.description, - type: "module", // Required for ESM - without this, Node.js defaults to CommonJS and shows warnings + type: "module", main: "dist/index.js", author: manifest.author, license: manifest.license, repository: manifest.repository, - dependencies: packageJson.dependencies // Use dependencies directly from package.json + dependencies: { + "sharp": packageJson.dependencies.sharp, + "@vscode/ripgrep": packageJson.dependencies["@vscode/ripgrep"], + "@opendocsg/pdf2md": packageJson.dependencies["@opendocsg/pdf2md"], + "pdf-lib": packageJson.dependencies["pdf-lib"] + } }; - fs.writeFileSync( - path.join(BUNDLE_DIR, 'package.json'), + path.join(BUNDLE_DIR, 'package.json'), JSON.stringify(bundlePackageJson, null, 2) ); @@ -142,28 +210,19 @@ try { const ripgrepBinDest = path.join(BUNDLE_DIR, 'node_modules/@vscode/ripgrep/bin'); const ripgrepWrapperSrc = path.join(PROJECT_ROOT, 'scripts/ripgrep-wrapper.js'); const ripgrepIndexDest = path.join(BUNDLE_DIR, 'node_modules/@vscode/ripgrep/lib/index.js'); - - // Ensure bin directory exists if (!fs.existsSync(ripgrepBinDest)) { fs.mkdirSync(ripgrepBinDest, { recursive: true }); } - - // Copy all platform-specific ripgrep binaries const binaries = fs.readdirSync(ripgrepBinSrc).filter(f => f.startsWith('rg-')); binaries.forEach(binary => { const src = path.join(ripgrepBinSrc, binary); const dest = path.join(ripgrepBinDest, binary); fs.copyFileSync(src, dest); - // Set executable permissions (for development/testing) - // Note: Zip archives don't preserve permissions reliably, so ripgrep-wrapper.js - // also sets permissions at runtime to ensure compatibility after extraction if (!binary.endsWith('.exe')) { fs.chmodSync(dest, 0o755); } }); console.log(`โœ… Copied ${binaries.length} ripgrep binaries`); - - // Replace index.js with our wrapper fs.copyFileSync(ripgrepWrapperSrc, ripgrepIndexDest); console.log('โœ… Installed ripgrep runtime wrapper'); } catch (error) { @@ -180,10 +239,10 @@ try { console.error('โŒ Manifest validation failed:', error.message); process.exit(1); } + // Step 8: Pack the bundle console.log('๐Ÿ“ฆ Creating .mcpb bundle...'); const outputFile = path.join(PROJECT_ROOT, `${manifest.name}-${manifest.version}.mcpb`); - try { execSync(`npx @anthropic-ai/mcpb pack "${BUNDLE_DIR}" "${outputFile}"`, { stdio: 'inherit' }); console.log('โœ… MCPB bundle created successfully!'); @@ -205,4 +264,4 @@ console.log(''); console.log('To submit to Anthropic directory:'); console.log('- Ensure privacy policy is accessible at the GitHub URL'); console.log('- Complete destructive operation annotations (โœ… Done)'); -console.log('- Submit via Anthropic desktop extensions interest form'); \ No newline at end of file +console.log('- Submit via Anthropic desktop extensions interest form'); diff --git a/src/utils/trackTools.ts b/src/utils/trackTools.ts index 0ea0f8ae..26906cfa 100644 --- a/src/utils/trackTools.ts +++ b/src/utils/trackTools.ts @@ -2,9 +2,10 @@ import * as fs from 'fs'; import * as path from 'path'; import { TOOL_CALL_FILE, TOOL_CALL_FILE_MAX_SIZE } from '../config.js'; -// Ensure the directory for the log file exists +// Ensure the directory for the log file exists (use sync to avoid top-level await +// which breaks esbuild ESM bundling due to async propagation issues in __esm wrappers) const logDir = path.dirname(TOOL_CALL_FILE); -await fs.promises.mkdir(logDir, { recursive: true }); +fs.mkdirSync(logDir, { recursive: true }); /** * Track tool calls and save them to a log file