diff --git a/packages/build/src/esbuild.ts b/packages/build/src/esbuild.ts index 3b793c2cc93..952e823eeca 100644 --- a/packages/build/src/esbuild.ts +++ b/packages/build/src/esbuild.ts @@ -158,6 +158,58 @@ export function copyWasms(srcDir: string, distDir: string): void { }) console.log(`[copyWasms] Copied ${wasmFiles.length} tree-sitter language wasms to ${distDir}`) + + // Copy esbuild-wasm files for custom tool transpilation (cross-platform). + copyEsbuildWasmFiles(nodeModulesDir, distDir) +} + +/** + * 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. + * + * Files copied: + * - bin/esbuild (Node.js CLI script) + * - esbuild.wasm (WASM binary) + * - wasm_exec_node.js (Go WASM runtime for Node.js) + * - wasm_exec.js (Go WASM runtime dependency) + */ +function copyEsbuildWasmFiles(nodeModulesDir: string, distDir: string): void { + const esbuildWasmDir = path.join(nodeModulesDir, "esbuild-wasm") + + if (!fs.existsSync(esbuildWasmDir)) { + throw new Error(`Directory does not exist: ${esbuildWasmDir}`) + } + + // 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") }, + { 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") }, + ] + + for (const { src, dest } of filesToCopy) { + fs.copyFileSync(src, dest) + + // Make CLI executable. + if (src.endsWith("esbuild")) { + try { + fs.chmodSync(dest, 0o755) + } catch { + // Ignore chmod errors on Windows. + } + } + } + + console.log(`[copyWasms] Copied ${filesToCopy.length} esbuild-wasm files to ${distDir}`) } export function copyLocales(srcDir: string, distDir: string): void { 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 new file mode 100644 index 00000000000..78581fc7c58 --- /dev/null +++ b/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts @@ -0,0 +1,156 @@ +import fs from "fs" +import os from "os" +import path from "path" + +import { getEsbuildScriptPath, runEsbuild } from "../esbuild-runner.js" + +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 { + 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..41384789212 --- /dev/null +++ b/packages/core/src/custom-tools/esbuild-runner.ts @@ -0,0 +1,175 @@ +/** + * 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 path from "path" +import fs from "fs" +import { fileURLToPath } from "url" +import { execa } from "execa" + +// 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. + 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 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 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") + + if (fs.existsSync(scriptPath)) { + return scriptPath + } + + // Also check src/node_modules for monorepo where src is a workspace. + const srcScriptPath = path.join(currentDir, "src", "node_modules", "esbuild-wasm", "bin", "esbuild") + + if (fs.existsSync(srcScriptPath)) { + return srcScriptPath + } + + currentDir = path.dirname(currentDir) + } + + 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) + } + + try { + 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}` + throw new Error(`esbuild failed: ${errorMessage}`) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23052bb3f00..66228d425c7 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) @@ -739,9 +742,6 @@ importers: diff-match-patch: specifier: ^1.0.5 version: 1.0.5 - esbuild: - specifier: '>=0.25.0' - version: 0.25.9 exceljs: specifier: ^4.4.0 version: 4.4.0 @@ -977,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 @@ -5832,6 +5835,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 +15720,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/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..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": "^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",