From 482cead10d2afe6f4c94bda7d61450db7a406f55 Mon Sep 17 00:00:00 2001 From: cte Date: Sat, 20 Dec 2025 15:02:11 -0800 Subject: [PATCH 1/7] Fix custom tool transpilation and bundling --- packages/build/src/esbuild.ts | 111 +++++++++ packages/build/src/index.ts | 9 +- .../__tests__/esbuild-runner.spec.ts | 189 ++++++++++++++ .../src/custom-tools/custom-tool-registry.ts | 50 ++-- .../core/src/custom-tools/esbuild-runner.ts | 232 ++++++++++++++++++ pnpm-lock.yaml | 13 +- src/esbuild.mjs | 6 +- src/extension.ts | 4 + src/package.json | 2 +- 9 files changed, 595 insertions(+), 21 deletions(-) create mode 100644 packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts create mode 100644 packages/core/src/custom-tools/esbuild-runner.ts diff --git a/packages/build/src/esbuild.ts b/packages/build/src/esbuild.ts index 3b793c2cc93..b988a48d68b 100644 --- a/packages/build/src/esbuild.ts +++ b/packages/build/src/esbuild.ts @@ -160,6 +160,117 @@ export function copyWasms(srcDir: string, distDir: string): void { console.log(`[copyWasms] Copied ${wasmFiles.length} tree-sitter language wasms to ${distDir}`) } +/** + * Find the esbuild-wasm package directory. + * Handles regular node_modules, pnpm hoisting, and monorepo structures. + */ +function findEsbuildWasmDir(srcDir: string): string | null { + const possiblePaths = [ + // Direct in node_modules (npm, yarn) + path.join(srcDir, "node_modules", "esbuild-wasm"), + // Parent directory's node_modules (monorepo with hoisting) + path.join(srcDir, "..", "node_modules", "esbuild-wasm"), + // Root node_modules in monorepo + path.join(srcDir, "..", "..", "node_modules", "esbuild-wasm"), + ] + + // Check each possible path + for (const possiblePath of possiblePaths) { + if (fs.existsSync(possiblePath)) { + return possiblePath + } + } + + // Check pnpm .pnpm directory structure + const pnpmPaths = [ + path.join(srcDir, "node_modules", ".pnpm"), + path.join(srcDir, "..", "node_modules", ".pnpm"), + path.join(srcDir, "..", "..", "node_modules", ".pnpm"), + ] + + for (const pnpmPath of pnpmPaths) { + if (fs.existsSync(pnpmPath)) { + try { + const dirs = fs.readdirSync(pnpmPath) + // Look for esbuild-wasm@ + const esbuildDir = dirs.find((dir) => dir.startsWith("esbuild-wasm@")) + + if (esbuildDir) { + const packagePath = path.join(pnpmPath, esbuildDir, "node_modules", "esbuild-wasm") + if (fs.existsSync(packagePath)) { + return packagePath + } + } + } catch { + // Ignore errors reading directories + } + } + } + + return null +} + +/** + * Copy esbuild-wasm files to the dist/bin directory. + * + * This function copies the esbuild-wasm CLI and WASM binary, which provides + * a cross-platform esbuild implementation that works on all platforms. + * This is simpler and smaller (~12MB) than bundling native binaries for all platforms. + * + * Files copied: + * - bin/esbuild (Node.js CLI script) + * - esbuild.wasm (WASM binary) + * - wasm_exec_node.js (Go WASM runtime for Node.js) + * + * @param srcDir - Source directory containing node_modules + * @param distDir - Destination dist directory + */ +export function copyEsbuildWasm(srcDir: string, distDir: string): void { + const esbuildWasmDir = findEsbuildWasmDir(srcDir) + + if (!esbuildWasmDir) { + console.warn("[copyEsbuildWasm] esbuild-wasm package not found, skipping") + return + } + + // Create bin directory in dist + const binDir = path.join(distDir, "bin") + fs.mkdirSync(binDir, { recursive: true }) + + // Files to copy - the esbuild CLI script expects wasm_exec_node.js and esbuild.wasm + // to be one directory level up from the bin directory (i.e., in distDir directly) + // wasm_exec_node.js requires wasm_exec.js, so we need to copy that too + const filesToCopy = [ + { src: path.join(esbuildWasmDir, "bin", "esbuild"), dest: path.join(binDir, "esbuild") }, + // wasm_exec_node.js, wasm_exec.js, and esbuild.wasm go in dist/, not dist/bin/ + { src: path.join(esbuildWasmDir, "esbuild.wasm"), dest: path.join(distDir, "esbuild.wasm") }, + { src: path.join(esbuildWasmDir, "wasm_exec_node.js"), dest: path.join(distDir, "wasm_exec_node.js") }, + { src: path.join(esbuildWasmDir, "wasm_exec.js"), dest: path.join(distDir, "wasm_exec.js") }, + ] + + let copiedCount = 0 + + for (const { src, dest } of filesToCopy) { + if (fs.existsSync(src)) { + fs.copyFileSync(src, dest) + copiedCount++ + + // Make CLI executable + if (src.endsWith("esbuild")) { + try { + fs.chmodSync(dest, 0o755) + } catch { + // Ignore chmod errors on Windows + } + } + } else { + console.warn(`[copyEsbuildWasm] File not found: ${src}`) + } + } + + console.log(`[copyEsbuildWasm] Copied ${copiedCount} esbuild-wasm files to ${distDir}`) +} + export function copyLocales(srcDir: string, distDir: string): void { const destDir = path.join(distDir, "i18n", "locales") fs.mkdirSync(destDir, { recursive: true }) diff --git a/packages/build/src/index.ts b/packages/build/src/index.ts index edbc994a2dd..8e183c2e8ea 100644 --- a/packages/build/src/index.ts +++ b/packages/build/src/index.ts @@ -1,2 +1,9 @@ export { getGitSha } from "./git.js" -export { copyPaths, copyWasms, copyLocales, setupLocaleWatcher, generatePackageJson } from "./esbuild.js" +export { + copyPaths, + copyWasms, + copyEsbuildWasm, + copyLocales, + setupLocaleWatcher, + generatePackageJson, +} from "./esbuild.js" diff --git a/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts b/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts new file mode 100644 index 00000000000..d8c2c38d87e --- /dev/null +++ b/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts @@ -0,0 +1,189 @@ +import fs from "fs" +import os from "os" +import path from "path" +import { fileURLToPath } from "url" + +import { getEsbuildScriptPath, runEsbuild } from "../esbuild-runner.js" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +describe("esbuild-runner", () => { + describe("getEsbuildScriptPath", () => { + it("should find esbuild-wasm script in node_modules in development", () => { + const scriptPath = getEsbuildScriptPath() + + // Should find the script + expect(typeof scriptPath).toBe("string") + expect(scriptPath.length).toBeGreaterThan(0) + + // The script should exist + expect(fs.existsSync(scriptPath)).toBe(true) + + // Should be the esbuild script (not a binary) + expect(scriptPath).toMatch(/esbuild$/) + }) + + it("should prefer production path when extensionPath is provided and script exists", () => { + // Create a temporary directory with a fake script + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "esbuild-runner-test-")) + const binDir = path.join(tempDir, "dist", "bin") + fs.mkdirSync(binDir, { recursive: true }) + + const fakeScriptPath = path.join(binDir, "esbuild") + fs.writeFileSync(fakeScriptPath, "#!/usr/bin/env node\nconsole.log('fake esbuild')") + + try { + const result = getEsbuildScriptPath(tempDir) + expect(result).toBe(fakeScriptPath) + } finally { + // Cleanup + fs.rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it("should fall back to node_modules when production script does not exist", () => { + // Pass a non-existent extension path + const result = getEsbuildScriptPath("/nonexistent/extension/path") + + // Should fall back to development path + expect(typeof result).toBe("string") + expect(result.length).toBeGreaterThan(0) + expect(fs.existsSync(result)).toBe(true) + }) + }) + + describe("runEsbuild", () => { + let tempDir: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "esbuild-runner-test-")) + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + it("should compile a TypeScript file to ESM", async () => { + // Create a simple TypeScript file + const inputFile = path.join(tempDir, "input.ts") + const outputFile = path.join(tempDir, "output.mjs") + + fs.writeFileSync( + inputFile, + ` + export const greeting = "Hello, World!" + export function add(a: number, b: number): number { + return a + b + } + `, + ) + + await runEsbuild({ + entryPoint: inputFile, + outfile: outputFile, + format: "esm", + platform: "node", + target: "node18", + bundle: true, + }) + + // Verify output file exists + expect(fs.existsSync(outputFile)).toBe(true) + + // Verify output content is valid JavaScript + const outputContent = fs.readFileSync(outputFile, "utf-8") + expect(outputContent).toContain("Hello, World!") + expect(outputContent).toContain("add") + }, 30000) + + it("should generate inline source maps when specified", async () => { + const inputFile = path.join(tempDir, "input.ts") + const outputFile = path.join(tempDir, "output.mjs") + + fs.writeFileSync(inputFile, `export const value = 42`) + + await runEsbuild({ + entryPoint: inputFile, + outfile: outputFile, + format: "esm", + sourcemap: "inline", + }) + + const outputContent = fs.readFileSync(outputFile, "utf-8") + expect(outputContent).toContain("sourceMappingURL=data:") + }, 30000) + + it("should throw an error for invalid TypeScript", async () => { + const inputFile = path.join(tempDir, "invalid.ts") + const outputFile = path.join(tempDir, "output.mjs") + + // Write syntactically invalid TypeScript + fs.writeFileSync(inputFile, `export const value = {{{ invalid syntax`) + + await expect( + runEsbuild({ + entryPoint: inputFile, + outfile: outputFile, + format: "esm", + }), + ).rejects.toThrow() + }, 30000) + + it("should throw an error for non-existent file", async () => { + const nonExistentFile = path.join(tempDir, "does-not-exist.ts") + const outputFile = path.join(tempDir, "output.mjs") + + await expect( + runEsbuild({ + entryPoint: nonExistentFile, + outfile: outputFile, + format: "esm", + }), + ).rejects.toThrow() + }, 30000) + + it("should bundle dependencies when bundle option is true", async () => { + // Create two files where one imports the other + const libFile = path.join(tempDir, "lib.ts") + const mainFile = path.join(tempDir, "main.ts") + const outputFile = path.join(tempDir, "output.mjs") + + fs.writeFileSync(libFile, `export const PI = 3.14159`) + fs.writeFileSync( + mainFile, + ` + import { PI } from "./lib.js" + export const circumference = (r: number) => 2 * PI * r + `, + ) + + await runEsbuild({ + entryPoint: mainFile, + outfile: outputFile, + format: "esm", + bundle: true, + }) + + const outputContent = fs.readFileSync(outputFile, "utf-8") + // The PI constant should be bundled inline + expect(outputContent).toContain("3.14159") + }, 30000) + + it("should respect platform option", async () => { + const inputFile = path.join(tempDir, "input.ts") + const outputFile = path.join(tempDir, "output.mjs") + + fs.writeFileSync(inputFile, `export const value = process.env.NODE_ENV`) + + await runEsbuild({ + entryPoint: inputFile, + outfile: outputFile, + format: "esm", + platform: "node", + }) + + // File should be created successfully + expect(fs.existsSync(outputFile)).toBe(true) + }, 30000) + }) +}) diff --git a/packages/core/src/custom-tools/custom-tool-registry.ts b/packages/core/src/custom-tools/custom-tool-registry.ts index aadc42cbda1..4ef88f50ec6 100644 --- a/packages/core/src/custom-tools/custom-tool-registry.ts +++ b/packages/core/src/custom-tools/custom-tool-registry.ts @@ -13,11 +13,10 @@ import path from "path" import { createHash } from "crypto" import os from "os" -import { build } from "esbuild" - import type { CustomToolDefinition, SerializedCustomToolDefinition, CustomToolParametersSchema } from "@roo-code/types" import { serializeCustomTool } from "./serialize.js" +import { runEsbuild } from "./esbuild-runner.js" export interface LoadResult { loaded: string[] @@ -29,6 +28,8 @@ export interface RegistryOptions { cacheDir?: string /** Additional paths for resolving node modules (useful for tools outside node_modules). */ nodePaths?: string[] + /** Path to the extension root directory (for finding bundled esbuild binary in production). */ + extensionPath?: string } export class CustomToolRegistry { @@ -36,12 +37,14 @@ export class CustomToolRegistry { private tsCache = new Map() private cacheDir: string private nodePaths: string[] + private extensionPath?: string private lastLoaded: Map = new Map() constructor(options?: RegistryOptions) { this.cacheDir = options?.cacheDir ?? path.join(os.tmpdir(), "dynamic-tools-cache") // Default to current working directory's node_modules. this.nodePaths = options?.nodePaths ?? [path.join(process.cwd(), "node_modules")] + this.extensionPath = options?.extensionPath } /** @@ -180,6 +183,21 @@ export class CustomToolRegistry { this.tools.clear() } + /** + * Set the extension path for finding bundled esbuild binary. + * This should be called with context.extensionPath when the extension activates. + */ + setExtensionPath(extensionPath: string): void { + this.extensionPath = extensionPath + } + + /** + * Get the current extension path. + */ + getExtensionPath(): string | undefined { + return this.extensionPath + } + /** * Clear the TypeScript compilation cache (both in-memory and on disk). */ @@ -229,19 +247,21 @@ export class CustomToolRegistry { const hash = createHash("sha256").update(cacheKey).digest("hex").slice(0, 16) const tempFile = path.join(this.cacheDir, `${hash}.mjs`) - // Bundle the TypeScript file with dependencies. - await build({ - entryPoints: [absolutePath], - bundle: true, - format: "esm", - platform: "node", - target: "node18", - outfile: tempFile, - sourcemap: "inline", - packages: "bundle", - // Include node_modules paths for module resolution. - nodePaths: this.nodePaths, - }) + // Bundle the TypeScript file with dependencies using esbuild CLI. + await runEsbuild( + { + entryPoint: absolutePath, + outfile: tempFile, + format: "esm", + platform: "node", + target: "node18", + bundle: true, + sourcemap: "inline", + packages: "bundle", + nodePaths: this.nodePaths, + }, + this.extensionPath, + ) this.tsCache.set(cacheKey, tempFile) return import(`file://${tempFile}`) diff --git a/packages/core/src/custom-tools/esbuild-runner.ts b/packages/core/src/custom-tools/esbuild-runner.ts new file mode 100644 index 00000000000..1be0b0dbb39 --- /dev/null +++ b/packages/core/src/custom-tools/esbuild-runner.ts @@ -0,0 +1,232 @@ +/** + * esbuild-runner - Runs esbuild-wasm CLI to transpile TypeScript files. + * + * This module provides a way to run esbuild as a CLI process instead of using + * the JavaScript API. This uses esbuild-wasm which is cross-platform and works + * on all operating systems without needing native binaries. + * + * In production, the esbuild-wasm CLI script is bundled in dist/bin/. + * In development, it falls back to using esbuild-wasm from node_modules. + */ + +import { spawn } from "child_process" +import path from "path" +import fs from "fs" +import { fileURLToPath } from "url" + +// Get the directory where this module is located +// Handle both ESM (import.meta.url) and bundled CJS contexts +function getModuleDir(): string | undefined { + try { + // In ESM context, import.meta.url is available + // In bundled CJS, this will throw or be undefined + if (typeof import.meta !== "undefined" && import.meta.url) { + return path.dirname(fileURLToPath(import.meta.url)) + } + } catch { + // Ignore errors, fall through to undefined + } + return undefined +} + +const moduleDir = getModuleDir() + +export interface EsbuildOptions { + /** Entry point file path (absolute) */ + entryPoint: string + /** Output file path (absolute) */ + outfile: string + /** Output format */ + format?: "esm" | "cjs" | "iife" + /** Target platform */ + platform?: "node" | "browser" | "neutral" + /** Target environment (e.g., "node18") */ + target?: string + /** Bundle dependencies */ + bundle?: boolean + /** Generate source maps */ + sourcemap?: boolean | "inline" | "external" + /** How to handle packages: "bundle" includes them, "external" leaves them */ + packages?: "bundle" | "external" + /** Additional paths for module resolution */ + nodePaths?: string[] +} + +/** + * Find the esbuild-wasm CLI script in various possible locations. + * Handles regular node_modules, pnpm hoisting, and monorepo structures. + */ +function findEsbuildWasmScript(startDir: string): string | null { + const possiblePaths = [ + // Direct in node_modules (npm, yarn) + path.join(startDir, "node_modules", "esbuild-wasm", "bin", "esbuild"), + // Parent directory's node_modules (monorepo with hoisting) + path.join(startDir, "..", "node_modules", "esbuild-wasm", "bin", "esbuild"), + // Root node_modules in monorepo + path.join(startDir, "..", "..", "node_modules", "esbuild-wasm", "bin", "esbuild"), + // Go up more levels for deeply nested packages + path.join(startDir, "..", "..", "..", "node_modules", "esbuild-wasm", "bin", "esbuild"), + path.join(startDir, "..", "..", "..", "..", "node_modules", "esbuild-wasm", "bin", "esbuild"), + path.join(startDir, "..", "..", "..", "..", "..", "node_modules", "esbuild-wasm", "bin", "esbuild"), + // src directory in monorepo + path.join(startDir, "src", "node_modules", "esbuild-wasm", "bin", "esbuild"), + ] + + // Check each possible path + for (const possiblePath of possiblePaths) { + const normalizedPath = path.normalize(possiblePath) + if (fs.existsSync(normalizedPath)) { + return normalizedPath + } + } + + // Check pnpm .pnpm directory structure + const pnpmPaths = [ + path.join(startDir, "node_modules", ".pnpm"), + path.join(startDir, "..", "node_modules", ".pnpm"), + path.join(startDir, "..", "..", "node_modules", ".pnpm"), + path.join(startDir, "..", "..", "..", "node_modules", ".pnpm"), + path.join(startDir, "..", "..", "..", "..", "node_modules", ".pnpm"), + path.join(startDir, "..", "..", "..", "..", "..", "node_modules", ".pnpm"), + ] + + for (const pnpmPath of pnpmPaths) { + const normalizedPnpmPath = path.normalize(pnpmPath) + if (fs.existsSync(normalizedPnpmPath)) { + try { + const dirs = fs.readdirSync(normalizedPnpmPath) + // Look for esbuild-wasm@ + const esbuildDir = dirs.find((dir) => dir.startsWith("esbuild-wasm@")) + + if (esbuildDir) { + const scriptPath = path.join( + normalizedPnpmPath, + esbuildDir, + "node_modules", + "esbuild-wasm", + "bin", + "esbuild", + ) + if (fs.existsSync(scriptPath)) { + return scriptPath + } + } + } catch { + // Ignore errors reading directories + } + } + } + + return null +} + +/** + * Get the path to the esbuild CLI script. + * + * Resolution order: + * 1. Production: Look in extension's dist/bin directory for bundled script + * 2. Development: Use esbuild-wasm from node_modules (relative to this module) + * 3. Fallback: Try process.cwd() as last resort + * + * @param extensionPath - Path to the extension's root directory (production) + * @returns Path to the esbuild CLI script + */ +export function getEsbuildScriptPath(extensionPath?: string): string { + // Production: look in extension's dist/bin directory + if (extensionPath) { + const prodPath = path.join(extensionPath, "dist", "bin", "esbuild") + if (fs.existsSync(prodPath)) { + return prodPath + } + } + + // Development: use esbuild-wasm from node_modules relative to this module + // This works when running the extension in debug mode (if moduleDir is available) + if (moduleDir) { + const devPath = findEsbuildWasmScript(moduleDir) + if (devPath) { + return devPath + } + } + + // Fallback: try from cwd (for tests and other contexts) + const cwdPath = findEsbuildWasmScript(process.cwd()) + if (cwdPath) { + return cwdPath + } + + throw new Error("esbuild-wasm CLI not found. Ensure esbuild-wasm is installed.") +} + +/** + * Run esbuild CLI to bundle a TypeScript file. + * + * Uses esbuild-wasm which is cross-platform and runs via Node.js. + * + * @param options - Build options + * @param extensionPath - Path to extension root (for finding bundled script) + * @returns Promise that resolves when build completes + * @throws Error if the build fails + */ +export async function runEsbuild(options: EsbuildOptions, extensionPath?: string): Promise { + const scriptPath = getEsbuildScriptPath(extensionPath) + + const args: string[] = [ + scriptPath, + options.entryPoint, + `--outfile=${options.outfile}`, + `--format=${options.format ?? "esm"}`, + `--platform=${options.platform ?? "node"}`, + `--target=${options.target ?? "node18"}`, + ] + + if (options.bundle !== false) { + args.push("--bundle") + } + + if (options.sourcemap) { + args.push(options.sourcemap === true ? "--sourcemap" : `--sourcemap=${options.sourcemap}`) + } + + if (options.packages) { + args.push(`--packages=${options.packages}`) + } + + // Build environment with NODE_PATH for module resolution + const env: NodeJS.ProcessEnv = { ...process.env } + if (options.nodePaths && options.nodePaths.length > 0) { + env.NODE_PATH = options.nodePaths.join(path.delimiter) + } + + return new Promise((resolve, reject) => { + // Spawn node to run the esbuild script + const proc = spawn(process.execPath, args, { + env, + stdio: ["ignore", "pipe", "pipe"], + }) + + let stdout = "" + let stderr = "" + + proc.stdout?.on("data", (data: Buffer) => { + stdout += data.toString() + }) + + proc.stderr?.on("data", (data: Buffer) => { + stderr += data.toString() + }) + + proc.on("close", (code: number | null) => { + if (code === 0) { + resolve() + } else { + const errorMessage = stderr || stdout || `esbuild exited with code ${code}` + reject(new Error(`esbuild failed: ${errorMessage}`)) + } + }) + + proc.on("error", (err: Error) => { + reject(new Error(`Failed to spawn esbuild: ${err.message}`)) + }) + }) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23052bb3f00..eb036fd556a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -739,9 +739,9 @@ importers: diff-match-patch: specifier: ^1.0.5 version: 1.0.5 - esbuild: - specifier: '>=0.25.0' - version: 0.25.9 + esbuild-wasm: + specifier: ^0.25.0 + version: 0.25.12 exceljs: specifier: ^4.4.0 version: 4.4.0 @@ -5832,6 +5832,11 @@ packages: peerDependencies: esbuild: '>=0.25.0' + esbuild-wasm@0.25.12: + resolution: {integrity: sha512-rZqkjL3Y6FwLpSHzLnaEy8Ps6veCNo1kZa9EOfJvmWtBq5dJH4iVjfmOO6Mlkv9B0tt9WFPFmb/VxlgJOnueNg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.25.9: resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==} engines: {node: '>=18'} @@ -15712,6 +15717,8 @@ snapshots: transitivePeerDependencies: - supports-color + esbuild-wasm@0.25.12: {} + esbuild@0.25.9: optionalDependencies: '@esbuild/aix-ppc64': 0.25.9 diff --git a/src/esbuild.mjs b/src/esbuild.mjs index 68298eb3de4..fa1b3a7c6e4 100644 --- a/src/esbuild.mjs +++ b/src/esbuild.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from "url" import process from "node:process" import * as console from "node:console" -import { copyPaths, copyWasms, copyLocales, setupLocaleWatcher } from "@roo-code/build" +import { copyPaths, copyWasms, copyEsbuildWasm, copyLocales, setupLocaleWatcher } from "@roo-code/build" const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -121,9 +121,13 @@ async function main() { await Promise.all([extensionCtx.watch(), workerCtx.watch()]) copyLocales(srcDir, distDir) setupLocaleWatcher(srcDir, distDir) + // Copy esbuild-wasm for custom tool transpilation + copyEsbuildWasm(srcDir, distDir) } else { await Promise.all([extensionCtx.rebuild(), workerCtx.rebuild()]) await Promise.all([extensionCtx.dispose(), workerCtx.dispose()]) + // Copy esbuild-wasm for custom tool transpilation (cross-platform) + copyEsbuildWasm(srcDir, distDir) } } diff --git a/src/extension.ts b/src/extension.ts index f601b9d7cff..dcb941fa581 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -15,6 +15,7 @@ try { import type { CloudUserInfo, AuthState } from "@roo-code/types" import { CloudService, BridgeOrchestrator } from "@roo-code/cloud" import { TelemetryService, PostHogTelemetryClient } from "@roo-code/telemetry" +import { customToolRegistry } from "@roo-code/core" import "./utils/path" // Necessary to have access to String.prototype.toPosix. import { createOutputChannelLogger, createDualLogger } from "./utils/outputChannelLogger" @@ -67,6 +68,9 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(outputChannel) outputChannel.appendLine(`${Package.name} extension activated - ${JSON.stringify(Package)}`) + // Set extension path for custom tool registry to find bundled esbuild + customToolRegistry.setExtensionPath(context.extensionPath) + // Migrate old settings to new await migrateSettings(context, outputChannel) diff --git a/src/package.json b/src/package.json index 0881b944c19..ca1137482c4 100644 --- a/src/package.json +++ b/src/package.json @@ -446,7 +446,7 @@ "@roo-code/telemetry": "workspace:^", "@roo-code/types": "workspace:^", "@vscode/codicons": "^0.0.36", - "esbuild": "^0.25.0", + "esbuild-wasm": "^0.25.0", "async-mutex": "^0.5.0", "axios": "^1.12.0", "cheerio": "^1.0.0", From 7e05c57b0f0305d55cef5b9e41a75b6774806deb Mon Sep 17 00:00:00 2001 From: cte Date: Sat, 20 Dec 2025 20:17:42 -0800 Subject: [PATCH 2/7] Simplify --- packages/build/src/esbuild.ts | 60 ++++++----------------------------- 1 file changed, 9 insertions(+), 51 deletions(-) diff --git a/packages/build/src/esbuild.ts b/packages/build/src/esbuild.ts index b988a48d68b..16a152a759b 100644 --- a/packages/build/src/esbuild.ts +++ b/packages/build/src/esbuild.ts @@ -162,52 +162,12 @@ export function copyWasms(srcDir: string, distDir: string): void { /** * Find the esbuild-wasm package directory. - * Handles regular node_modules, pnpm hoisting, and monorepo structures. + * In our pnpm monorepo, esbuild-wasm is declared in src/package.json and pnpm + * symlinks it to src/node_modules/esbuild-wasm. */ function findEsbuildWasmDir(srcDir: string): string | null { - const possiblePaths = [ - // Direct in node_modules (npm, yarn) - path.join(srcDir, "node_modules", "esbuild-wasm"), - // Parent directory's node_modules (monorepo with hoisting) - path.join(srcDir, "..", "node_modules", "esbuild-wasm"), - // Root node_modules in monorepo - path.join(srcDir, "..", "..", "node_modules", "esbuild-wasm"), - ] - - // Check each possible path - for (const possiblePath of possiblePaths) { - if (fs.existsSync(possiblePath)) { - return possiblePath - } - } - - // Check pnpm .pnpm directory structure - const pnpmPaths = [ - path.join(srcDir, "node_modules", ".pnpm"), - path.join(srcDir, "..", "node_modules", ".pnpm"), - path.join(srcDir, "..", "..", "node_modules", ".pnpm"), - ] - - for (const pnpmPath of pnpmPaths) { - if (fs.existsSync(pnpmPath)) { - try { - const dirs = fs.readdirSync(pnpmPath) - // Look for esbuild-wasm@ - const esbuildDir = dirs.find((dir) => dir.startsWith("esbuild-wasm@")) - - if (esbuildDir) { - const packagePath = path.join(pnpmPath, esbuildDir, "node_modules", "esbuild-wasm") - if (fs.existsSync(packagePath)) { - return packagePath - } - } - } catch { - // Ignore errors reading directories - } - } - } - - return null + const esbuildWasmPath = path.join(srcDir, "node_modules", "esbuild-wasm") + return fs.existsSync(esbuildWasmPath) ? esbuildWasmPath : null } /** @@ -215,7 +175,6 @@ function findEsbuildWasmDir(srcDir: string): string | null { * * This function copies the esbuild-wasm CLI and WASM binary, which provides * a cross-platform esbuild implementation that works on all platforms. - * This is simpler and smaller (~12MB) than bundling native binaries for all platforms. * * Files copied: * - bin/esbuild (Node.js CLI script) @@ -233,16 +192,15 @@ export function copyEsbuildWasm(srcDir: string, distDir: string): void { return } - // Create bin directory in dist + // Create bin directory in dist. const binDir = path.join(distDir, "bin") fs.mkdirSync(binDir, { recursive: true }) // Files to copy - the esbuild CLI script expects wasm_exec_node.js and esbuild.wasm - // to be one directory level up from the bin directory (i.e., in distDir directly) - // wasm_exec_node.js requires wasm_exec.js, so we need to copy that too + // to be one directory level up from the bin directory (i.e., in distDir directly). + // wasm_exec_node.js requires wasm_exec.js, so we need to copy that too. const filesToCopy = [ { src: path.join(esbuildWasmDir, "bin", "esbuild"), dest: path.join(binDir, "esbuild") }, - // wasm_exec_node.js, wasm_exec.js, and esbuild.wasm go in dist/, not dist/bin/ { src: path.join(esbuildWasmDir, "esbuild.wasm"), dest: path.join(distDir, "esbuild.wasm") }, { src: path.join(esbuildWasmDir, "wasm_exec_node.js"), dest: path.join(distDir, "wasm_exec_node.js") }, { src: path.join(esbuildWasmDir, "wasm_exec.js"), dest: path.join(distDir, "wasm_exec.js") }, @@ -255,12 +213,12 @@ export function copyEsbuildWasm(srcDir: string, distDir: string): void { fs.copyFileSync(src, dest) copiedCount++ - // Make CLI executable + // Make CLI executable. if (src.endsWith("esbuild")) { try { fs.chmodSync(dest, 0o755) } catch { - // Ignore chmod errors on Windows + // Ignore chmod errors on Windows. } } } else { From 8e0adc00e26ab528bc2ecdae5948a227b2c26d57 Mon Sep 17 00:00:00 2001 From: cte Date: Sat, 20 Dec 2025 20:20:07 -0800 Subject: [PATCH 3/7] PR feedback --- .../__tests__/esbuild-runner.spec.ts | 294 ++++++++---------- 1 file changed, 133 insertions(+), 161 deletions(-) diff --git a/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts b/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts index d8c2c38d87e..6428b84d68e 100644 --- a/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts +++ b/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts @@ -1,189 +1,161 @@ import fs from "fs" import os from "os" import path from "path" -import { fileURLToPath } from "url" import { getEsbuildScriptPath, runEsbuild } from "../esbuild-runner.js" -const __dirname = path.dirname(fileURLToPath(import.meta.url)) +describe("getEsbuildScriptPath", () => { + it("should find esbuild-wasm script in node_modules in development", () => { + const scriptPath = getEsbuildScriptPath() -describe("esbuild-runner", () => { - describe("getEsbuildScriptPath", () => { - it("should find esbuild-wasm script in node_modules in development", () => { - const scriptPath = getEsbuildScriptPath() + // Should find the script. + expect(typeof scriptPath).toBe("string") + expect(scriptPath.length).toBeGreaterThan(0) - // Should find the script - expect(typeof scriptPath).toBe("string") - expect(scriptPath.length).toBeGreaterThan(0) + // The script should exist. + expect(fs.existsSync(scriptPath)).toBe(true) - // The script should exist - expect(fs.existsSync(scriptPath)).toBe(true) + // Should be the esbuild script (not a binary). + expect(scriptPath).toMatch(/esbuild$/) + }) - // Should be the esbuild script (not a binary) - expect(scriptPath).toMatch(/esbuild$/) - }) + it("should prefer production path when extensionPath is provided and script exists", () => { + // Create a temporary directory with a fake script. + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "esbuild-runner-test-")) + const binDir = path.join(tempDir, "dist", "bin") + fs.mkdirSync(binDir, { recursive: true }) - it("should prefer production path when extensionPath is provided and script exists", () => { - // Create a temporary directory with a fake script - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "esbuild-runner-test-")) - const binDir = path.join(tempDir, "dist", "bin") - fs.mkdirSync(binDir, { recursive: true }) - - const fakeScriptPath = path.join(binDir, "esbuild") - fs.writeFileSync(fakeScriptPath, "#!/usr/bin/env node\nconsole.log('fake esbuild')") - - try { - const result = getEsbuildScriptPath(tempDir) - expect(result).toBe(fakeScriptPath) - } finally { - // Cleanup - fs.rmSync(tempDir, { recursive: true, force: true }) - } - }) + const fakeScriptPath = path.join(binDir, "esbuild") + fs.writeFileSync(fakeScriptPath, "#!/usr/bin/env node\nconsole.log('fake esbuild')") - it("should fall back to node_modules when production script does not exist", () => { - // Pass a non-existent extension path - const result = getEsbuildScriptPath("/nonexistent/extension/path") + try { + const result = getEsbuildScriptPath(tempDir) + expect(result).toBe(fakeScriptPath) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } + }) - // Should fall back to development path - expect(typeof result).toBe("string") - expect(result.length).toBeGreaterThan(0) - expect(fs.existsSync(result)).toBe(true) - }) + it("should fall back to node_modules when production script does not exist", () => { + // Pass a non-existent extension path. + const result = getEsbuildScriptPath("/nonexistent/extension/path") + + // Should fall back to development path. + expect(typeof result).toBe("string") + expect(result.length).toBeGreaterThan(0) + expect(fs.existsSync(result)).toBe(true) }) +}) - describe("runEsbuild", () => { - let tempDir: string +describe("runEsbuild", () => { + let tempDir: string - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "esbuild-runner-test-")) - }) + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "esbuild-runner-test-")) + }) - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }) - }) + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) - it("should compile a TypeScript file to ESM", async () => { - // Create a simple TypeScript file - const inputFile = path.join(tempDir, "input.ts") - const outputFile = path.join(tempDir, "output.mjs") + it("should compile a TypeScript file to ESM", async () => { + // Create a simple TypeScript file. + const inputFile = path.join(tempDir, "input.ts") + const outputFile = path.join(tempDir, "output.mjs") - fs.writeFileSync( - inputFile, - ` + fs.writeFileSync( + inputFile, + ` export const greeting = "Hello, World!" export function add(a: number, b: number): number { return a + b } `, - ) - - await runEsbuild({ - entryPoint: inputFile, - outfile: outputFile, - format: "esm", - platform: "node", - target: "node18", - bundle: true, - }) - - // Verify output file exists - expect(fs.existsSync(outputFile)).toBe(true) - - // Verify output content is valid JavaScript - const outputContent = fs.readFileSync(outputFile, "utf-8") - expect(outputContent).toContain("Hello, World!") - expect(outputContent).toContain("add") - }, 30000) - - it("should generate inline source maps when specified", async () => { - const inputFile = path.join(tempDir, "input.ts") - const outputFile = path.join(tempDir, "output.mjs") - - fs.writeFileSync(inputFile, `export const value = 42`) - - await runEsbuild({ - entryPoint: inputFile, - outfile: outputFile, - format: "esm", - sourcemap: "inline", - }) - - const outputContent = fs.readFileSync(outputFile, "utf-8") - expect(outputContent).toContain("sourceMappingURL=data:") - }, 30000) - - it("should throw an error for invalid TypeScript", async () => { - const inputFile = path.join(tempDir, "invalid.ts") - const outputFile = path.join(tempDir, "output.mjs") - - // Write syntactically invalid TypeScript - fs.writeFileSync(inputFile, `export const value = {{{ invalid syntax`) - - await expect( - runEsbuild({ - entryPoint: inputFile, - outfile: outputFile, - format: "esm", - }), - ).rejects.toThrow() - }, 30000) - - it("should throw an error for non-existent file", async () => { - const nonExistentFile = path.join(tempDir, "does-not-exist.ts") - const outputFile = path.join(tempDir, "output.mjs") - - await expect( - runEsbuild({ - entryPoint: nonExistentFile, - outfile: outputFile, - format: "esm", - }), - ).rejects.toThrow() - }, 30000) - - it("should bundle dependencies when bundle option is true", async () => { - // Create two files where one imports the other - const libFile = path.join(tempDir, "lib.ts") - const mainFile = path.join(tempDir, "main.ts") - const outputFile = path.join(tempDir, "output.mjs") - - fs.writeFileSync(libFile, `export const PI = 3.14159`) - fs.writeFileSync( - mainFile, - ` + ) + + await runEsbuild({ + entryPoint: inputFile, + outfile: outputFile, + format: "esm", + platform: "node", + target: "node18", + bundle: true, + }) + + // Verify output file exists. + expect(fs.existsSync(outputFile)).toBe(true) + + // Verify output content is valid JavaScript. + const outputContent = fs.readFileSync(outputFile, "utf-8") + expect(outputContent).toContain("Hello, World!") + expect(outputContent).toContain("add") + }, 30000) + + it("should generate inline source maps when specified", async () => { + const inputFile = path.join(tempDir, "input.ts") + const outputFile = path.join(tempDir, "output.mjs") + + fs.writeFileSync(inputFile, `export const value = 42`) + + await runEsbuild({ + entryPoint: inputFile, + outfile: outputFile, + format: "esm", + sourcemap: "inline", + }) + + const outputContent = fs.readFileSync(outputFile, "utf-8") + expect(outputContent).toContain("sourceMappingURL=data:") + }, 30000) + + it("should throw an error for invalid TypeScript", async () => { + const inputFile = path.join(tempDir, "invalid.ts") + const outputFile = path.join(tempDir, "output.mjs") + + // Write syntactically invalid TypeScript. + fs.writeFileSync(inputFile, `export const value = {{{ invalid syntax`) + + await expect(runEsbuild({ entryPoint: inputFile, outfile: outputFile, format: "esm" })).rejects.toThrow() + }, 30000) + + it("should throw an error for non-existent file", async () => { + const nonExistentFile = path.join(tempDir, "does-not-exist.ts") + const outputFile = path.join(tempDir, "output.mjs") + + await expect(runEsbuild({ entryPoint: nonExistentFile, outfile: outputFile, format: "esm" })).rejects.toThrow() + }, 30000) + + it("should bundle dependencies when bundle option is true", async () => { + // Create two files where one imports the other. + const libFile = path.join(tempDir, "lib.ts") + const mainFile = path.join(tempDir, "main.ts") + const outputFile = path.join(tempDir, "output.mjs") + + fs.writeFileSync(libFile, `export const PI = 3.14159`) + fs.writeFileSync( + mainFile, + ` import { PI } from "./lib.js" export const circumference = (r: number) => 2 * PI * r `, - ) - - await runEsbuild({ - entryPoint: mainFile, - outfile: outputFile, - format: "esm", - bundle: true, - }) - - const outputContent = fs.readFileSync(outputFile, "utf-8") - // The PI constant should be bundled inline - expect(outputContent).toContain("3.14159") - }, 30000) - - it("should respect platform option", async () => { - const inputFile = path.join(tempDir, "input.ts") - const outputFile = path.join(tempDir, "output.mjs") - - fs.writeFileSync(inputFile, `export const value = process.env.NODE_ENV`) - - await runEsbuild({ - entryPoint: inputFile, - outfile: outputFile, - format: "esm", - platform: "node", - }) - - // File should be created successfully - expect(fs.existsSync(outputFile)).toBe(true) - }, 30000) - }) + ) + + await runEsbuild({ entryPoint: mainFile, outfile: outputFile, format: "esm", bundle: true }) + + const outputContent = fs.readFileSync(outputFile, "utf-8") + // The PI constant should be bundled inline. + expect(outputContent).toContain("3.14159") + }, 30000) + + it("should respect platform option", async () => { + const inputFile = path.join(tempDir, "input.ts") + const outputFile = path.join(tempDir, "output.mjs") + + fs.writeFileSync(inputFile, `export const value = process.env.NODE_ENV`) + + await runEsbuild({ entryPoint: inputFile, outfile: outputFile, format: "esm", platform: "node" }) + + // File should be created successfully. + expect(fs.existsSync(outputFile)).toBe(true) + }, 30000) }) From efcec2810d5ed6c557114b72a8d5488d5fb6723e Mon Sep 17 00:00:00 2001 From: cte Date: Sat, 20 Dec 2025 20:27:40 -0800 Subject: [PATCH 4/7] Use execa --- packages/core/package.json | 1 + .../__tests__/esbuild-runner.spec.ts | 7 +- .../core/src/custom-tools/esbuild-runner.ts | 139 ++++++------------ pnpm-lock.yaml | 3 + 4 files changed, 47 insertions(+), 103 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 99f1b26fb03..5151d88d951 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -13,6 +13,7 @@ "dependencies": { "@roo-code/types": "workspace:^", "esbuild": "^0.25.0", + "execa": "^9.5.2", "openai": "^5.12.2", "zod": "^3.25.61" }, diff --git a/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts b/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts index 6428b84d68e..78581fc7c58 100644 --- a/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts +++ b/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts @@ -97,12 +97,7 @@ describe("runEsbuild", () => { fs.writeFileSync(inputFile, `export const value = 42`) - await runEsbuild({ - entryPoint: inputFile, - outfile: outputFile, - format: "esm", - sourcemap: "inline", - }) + await runEsbuild({ entryPoint: inputFile, outfile: outputFile, format: "esm", sourcemap: "inline" }) const outputContent = fs.readFileSync(outputFile, "utf-8") expect(outputContent).toContain("sourceMappingURL=data:") diff --git a/packages/core/src/custom-tools/esbuild-runner.ts b/packages/core/src/custom-tools/esbuild-runner.ts index 1be0b0dbb39..61fc83f0123 100644 --- a/packages/core/src/custom-tools/esbuild-runner.ts +++ b/packages/core/src/custom-tools/esbuild-runner.ts @@ -9,23 +9,23 @@ * In development, it falls back to using esbuild-wasm from node_modules. */ -import { spawn } from "child_process" import path from "path" import fs from "fs" import { fileURLToPath } from "url" +import { execa } from "execa" -// Get the directory where this module is located -// Handle both ESM (import.meta.url) and bundled CJS contexts +// Get the directory where this module is located. function getModuleDir(): string | undefined { try { - // In ESM context, import.meta.url is available - // In bundled CJS, this will throw or be undefined + // In ESM context, import.meta.url is available. + // In bundled CJS, this will throw or be undefined. if (typeof import.meta !== "undefined" && import.meta.url) { return path.dirname(fileURLToPath(import.meta.url)) } } catch { - // Ignore errors, fall through to undefined + // Ignore errors, fall through to undefined. } + return undefined } @@ -53,68 +53,31 @@ export interface EsbuildOptions { } /** - * Find the esbuild-wasm CLI script in various possible locations. - * Handles regular node_modules, pnpm hoisting, and monorepo structures. + * Find the esbuild-wasm CLI script by walking up the directory tree. + * In pnpm monorepos, node_modules/esbuild-wasm is a symlink to the actual package, + * so we don't need special pnpm handling. */ function findEsbuildWasmScript(startDir: string): string | null { - const possiblePaths = [ - // Direct in node_modules (npm, yarn) - path.join(startDir, "node_modules", "esbuild-wasm", "bin", "esbuild"), - // Parent directory's node_modules (monorepo with hoisting) - path.join(startDir, "..", "node_modules", "esbuild-wasm", "bin", "esbuild"), - // Root node_modules in monorepo - path.join(startDir, "..", "..", "node_modules", "esbuild-wasm", "bin", "esbuild"), - // Go up more levels for deeply nested packages - path.join(startDir, "..", "..", "..", "node_modules", "esbuild-wasm", "bin", "esbuild"), - path.join(startDir, "..", "..", "..", "..", "node_modules", "esbuild-wasm", "bin", "esbuild"), - path.join(startDir, "..", "..", "..", "..", "..", "node_modules", "esbuild-wasm", "bin", "esbuild"), - // src directory in monorepo - path.join(startDir, "src", "node_modules", "esbuild-wasm", "bin", "esbuild"), - ] + const maxDepth = 10 + let currentDir = path.resolve(startDir) + const root = path.parse(currentDir).root + + for (let i = 0; i < maxDepth && currentDir !== root; i++) { + // Check node_modules/esbuild-wasm/bin/esbuild at this level. + const scriptPath = path.join(currentDir, "node_modules", "esbuild-wasm", "bin", "esbuild") - // Check each possible path - for (const possiblePath of possiblePaths) { - const normalizedPath = path.normalize(possiblePath) - if (fs.existsSync(normalizedPath)) { - return normalizedPath + if (fs.existsSync(scriptPath)) { + return scriptPath } - } - // Check pnpm .pnpm directory structure - const pnpmPaths = [ - path.join(startDir, "node_modules", ".pnpm"), - path.join(startDir, "..", "node_modules", ".pnpm"), - path.join(startDir, "..", "..", "node_modules", ".pnpm"), - path.join(startDir, "..", "..", "..", "node_modules", ".pnpm"), - path.join(startDir, "..", "..", "..", "..", "node_modules", ".pnpm"), - path.join(startDir, "..", "..", "..", "..", "..", "node_modules", ".pnpm"), - ] + // Also check src/node_modules for monorepo where src is a workspace. + const srcScriptPath = path.join(currentDir, "src", "node_modules", "esbuild-wasm", "bin", "esbuild") - for (const pnpmPath of pnpmPaths) { - const normalizedPnpmPath = path.normalize(pnpmPath) - if (fs.existsSync(normalizedPnpmPath)) { - try { - const dirs = fs.readdirSync(normalizedPnpmPath) - // Look for esbuild-wasm@ - const esbuildDir = dirs.find((dir) => dir.startsWith("esbuild-wasm@")) - - if (esbuildDir) { - const scriptPath = path.join( - normalizedPnpmPath, - esbuildDir, - "node_modules", - "esbuild-wasm", - "bin", - "esbuild", - ) - if (fs.existsSync(scriptPath)) { - return scriptPath - } - } - } catch { - // Ignore errors reading directories - } + if (fs.existsSync(srcScriptPath)) { + return srcScriptPath } + + currentDir = path.dirname(currentDir) } return null @@ -124,33 +87,36 @@ function findEsbuildWasmScript(startDir: string): string | null { * Get the path to the esbuild CLI script. * * Resolution order: - * 1. Production: Look in extension's dist/bin directory for bundled script - * 2. Development: Use esbuild-wasm from node_modules (relative to this module) - * 3. Fallback: Try process.cwd() as last resort + * 1. Production: Look in extension's dist/bin directory for bundled script. + * 2. Development: Use esbuild-wasm from node_modules (relative to this module). + * 3. Fallback: Try process.cwd() as last resort. * * @param extensionPath - Path to the extension's root directory (production) * @returns Path to the esbuild CLI script */ export function getEsbuildScriptPath(extensionPath?: string): string { - // Production: look in extension's dist/bin directory + // Production: look in extension's dist/bin directory. if (extensionPath) { const prodPath = path.join(extensionPath, "dist", "bin", "esbuild") + if (fs.existsSync(prodPath)) { return prodPath } } - // Development: use esbuild-wasm from node_modules relative to this module - // This works when running the extension in debug mode (if moduleDir is available) + // Development: use esbuild-wasm from node_modules relative to this module. + // This works when running the extension in debug mode (if moduleDir is available). if (moduleDir) { const devPath = findEsbuildWasmScript(moduleDir) + if (devPath) { return devPath } } - // Fallback: try from cwd (for tests and other contexts) + // Fallback: try from cwd (for tests and other contexts). const cwdPath = findEsbuildWasmScript(process.cwd()) + if (cwdPath) { return cwdPath } @@ -198,35 +164,14 @@ export async function runEsbuild(options: EsbuildOptions, extensionPath?: string env.NODE_PATH = options.nodePaths.join(path.delimiter) } - return new Promise((resolve, reject) => { - // Spawn node to run the esbuild script - const proc = spawn(process.execPath, args, { + try { + await execa(process.execPath, args, { env, - stdio: ["ignore", "pipe", "pipe"], + stdin: "ignore", }) - - let stdout = "" - let stderr = "" - - proc.stdout?.on("data", (data: Buffer) => { - stdout += data.toString() - }) - - proc.stderr?.on("data", (data: Buffer) => { - stderr += data.toString() - }) - - proc.on("close", (code: number | null) => { - if (code === 0) { - resolve() - } else { - const errorMessage = stderr || stdout || `esbuild exited with code ${code}` - reject(new Error(`esbuild failed: ${errorMessage}`)) - } - }) - - proc.on("error", (err: Error) => { - reject(new Error(`Failed to spawn esbuild: ${err.message}`)) - }) - }) + } catch (error) { + const execaError = error as { stderr?: string; stdout?: string; exitCode?: number; message: string } + const errorMessage = execaError.stderr || execaError.stdout || `esbuild exited with code ${execaError.exitCode}` + throw new Error(`esbuild failed: ${errorMessage}`) + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb036fd556a..00923c2abe2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -497,6 +497,9 @@ importers: esbuild: specifier: '>=0.25.0' version: 0.25.9 + execa: + specifier: ^9.5.2 + version: 9.6.0 openai: specifier: ^5.12.2 version: 5.12.2(ws@8.18.3)(zod@3.25.76) From d79ccf40fc19402c9adeb0a046437fa9850a3db0 Mon Sep 17 00:00:00 2001 From: cte Date: Sat, 20 Dec 2025 20:32:34 -0800 Subject: [PATCH 5/7] Simplify more stuff --- packages/build/src/esbuild.ts | 49 ++++++++++++----------------------- packages/build/src/index.ts | 9 +------ src/esbuild.mjs | 6 +---- 3 files changed, 18 insertions(+), 46 deletions(-) diff --git a/packages/build/src/esbuild.ts b/packages/build/src/esbuild.ts index 16a152a759b..952e823eeca 100644 --- a/packages/build/src/esbuild.ts +++ b/packages/build/src/esbuild.ts @@ -158,16 +158,9 @@ export function copyWasms(srcDir: string, distDir: string): void { }) console.log(`[copyWasms] Copied ${wasmFiles.length} tree-sitter language wasms to ${distDir}`) -} -/** - * Find the esbuild-wasm package directory. - * In our pnpm monorepo, esbuild-wasm is declared in src/package.json and pnpm - * symlinks it to src/node_modules/esbuild-wasm. - */ -function findEsbuildWasmDir(srcDir: string): string | null { - const esbuildWasmPath = path.join(srcDir, "node_modules", "esbuild-wasm") - return fs.existsSync(esbuildWasmPath) ? esbuildWasmPath : null + // Copy esbuild-wasm files for custom tool transpilation (cross-platform). + copyEsbuildWasmFiles(nodeModulesDir, distDir) } /** @@ -180,16 +173,13 @@ function findEsbuildWasmDir(srcDir: string): string | null { * - bin/esbuild (Node.js CLI script) * - esbuild.wasm (WASM binary) * - wasm_exec_node.js (Go WASM runtime for Node.js) - * - * @param srcDir - Source directory containing node_modules - * @param distDir - Destination dist directory + * - wasm_exec.js (Go WASM runtime dependency) */ -export function copyEsbuildWasm(srcDir: string, distDir: string): void { - const esbuildWasmDir = findEsbuildWasmDir(srcDir) +function copyEsbuildWasmFiles(nodeModulesDir: string, distDir: string): void { + const esbuildWasmDir = path.join(nodeModulesDir, "esbuild-wasm") - if (!esbuildWasmDir) { - console.warn("[copyEsbuildWasm] esbuild-wasm package not found, skipping") - return + if (!fs.existsSync(esbuildWasmDir)) { + throw new Error(`Directory does not exist: ${esbuildWasmDir}`) } // Create bin directory in dist. @@ -206,27 +196,20 @@ export function copyEsbuildWasm(srcDir: string, distDir: string): void { { src: path.join(esbuildWasmDir, "wasm_exec.js"), dest: path.join(distDir, "wasm_exec.js") }, ] - let copiedCount = 0 - for (const { src, dest } of filesToCopy) { - if (fs.existsSync(src)) { - fs.copyFileSync(src, dest) - copiedCount++ - - // Make CLI executable. - if (src.endsWith("esbuild")) { - try { - fs.chmodSync(dest, 0o755) - } catch { - // Ignore chmod errors on Windows. - } + fs.copyFileSync(src, dest) + + // Make CLI executable. + if (src.endsWith("esbuild")) { + try { + fs.chmodSync(dest, 0o755) + } catch { + // Ignore chmod errors on Windows. } - } else { - console.warn(`[copyEsbuildWasm] File not found: ${src}`) } } - console.log(`[copyEsbuildWasm] Copied ${copiedCount} esbuild-wasm files to ${distDir}`) + console.log(`[copyWasms] Copied ${filesToCopy.length} esbuild-wasm files to ${distDir}`) } export function copyLocales(srcDir: string, distDir: string): void { diff --git a/packages/build/src/index.ts b/packages/build/src/index.ts index 8e183c2e8ea..edbc994a2dd 100644 --- a/packages/build/src/index.ts +++ b/packages/build/src/index.ts @@ -1,9 +1,2 @@ export { getGitSha } from "./git.js" -export { - copyPaths, - copyWasms, - copyEsbuildWasm, - copyLocales, - setupLocaleWatcher, - generatePackageJson, -} from "./esbuild.js" +export { copyPaths, copyWasms, copyLocales, setupLocaleWatcher, generatePackageJson } from "./esbuild.js" diff --git a/src/esbuild.mjs b/src/esbuild.mjs index fa1b3a7c6e4..68298eb3de4 100644 --- a/src/esbuild.mjs +++ b/src/esbuild.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from "url" import process from "node:process" import * as console from "node:console" -import { copyPaths, copyWasms, copyEsbuildWasm, copyLocales, setupLocaleWatcher } from "@roo-code/build" +import { copyPaths, copyWasms, copyLocales, setupLocaleWatcher } from "@roo-code/build" const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -121,13 +121,9 @@ async function main() { await Promise.all([extensionCtx.watch(), workerCtx.watch()]) copyLocales(srcDir, distDir) setupLocaleWatcher(srcDir, distDir) - // Copy esbuild-wasm for custom tool transpilation - copyEsbuildWasm(srcDir, distDir) } else { await Promise.all([extensionCtx.rebuild(), workerCtx.rebuild()]) await Promise.all([extensionCtx.dispose(), workerCtx.dispose()]) - // Copy esbuild-wasm for custom tool transpilation (cross-platform) - copyEsbuildWasm(srcDir, distDir) } } From d47094547a78f600d3248b2ec64dae3cae76c7cb Mon Sep 17 00:00:00 2001 From: cte Date: Sat, 20 Dec 2025 20:34:39 -0800 Subject: [PATCH 6/7] This should be a dev dependency --- pnpm-lock.yaml | 6 +++--- src/package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00923c2abe2..66228d425c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -742,9 +742,6 @@ importers: diff-match-patch: specifier: ^1.0.5 version: 1.0.5 - esbuild-wasm: - specifier: ^0.25.0 - version: 0.25.12 exceljs: specifier: ^4.4.0 version: 4.4.0 @@ -980,6 +977,9 @@ importers: '@vscode/vsce': specifier: 3.3.2 version: 3.3.2 + esbuild-wasm: + specifier: ^0.25.0 + version: 0.25.12 execa: specifier: ^9.5.2 version: 9.5.3 diff --git a/src/package.json b/src/package.json index ca1137482c4..ed43df811d6 100644 --- a/src/package.json +++ b/src/package.json @@ -446,7 +446,6 @@ "@roo-code/telemetry": "workspace:^", "@roo-code/types": "workspace:^", "@vscode/codicons": "^0.0.36", - "esbuild-wasm": "^0.25.0", "async-mutex": "^0.5.0", "axios": "^1.12.0", "cheerio": "^1.0.0", @@ -536,6 +535,7 @@ "@types/vscode": "^1.84.0", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "3.3.2", + "esbuild-wasm": "^0.25.0", "execa": "^9.5.2", "glob": "^11.1.0", "mkdirp": "^3.0.1", From af6d5bebd25c8126d1fb9bd1f9da799c17bb4708 Mon Sep 17 00:00:00 2001 From: cte Date: Sat, 20 Dec 2025 20:36:28 -0800 Subject: [PATCH 7/7] Cleanup --- packages/core/src/custom-tools/esbuild-runner.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/core/src/custom-tools/esbuild-runner.ts b/packages/core/src/custom-tools/esbuild-runner.ts index 61fc83f0123..41384789212 100644 --- a/packages/core/src/custom-tools/esbuild-runner.ts +++ b/packages/core/src/custom-tools/esbuild-runner.ts @@ -158,17 +158,15 @@ export async function runEsbuild(options: EsbuildOptions, extensionPath?: string args.push(`--packages=${options.packages}`) } - // Build environment with NODE_PATH for module resolution + // Build environment with NODE_PATH for module resolution. const env: NodeJS.ProcessEnv = { ...process.env } + if (options.nodePaths && options.nodePaths.length > 0) { env.NODE_PATH = options.nodePaths.join(path.delimiter) } try { - await execa(process.execPath, args, { - env, - stdin: "ignore", - }) + await execa(process.execPath, args, { env, stdin: "ignore" }) } catch (error) { const execaError = error as { stderr?: string; stdout?: string; exitCode?: number; message: string } const errorMessage = execaError.stderr || execaError.stdout || `esbuild exited with code ${execaError.exitCode}`