import * as fs from "fs" import * as path from "path" import { execSync } from "child_process" import { ViewsContainer, Views, Menus, Configuration, Keybindings, contributesSchema } from "./types.js" function copyDir(srcDir: string, dstDir: string, count: number): number { const entries = fs.readdirSync(srcDir, { withFileTypes: true }) for (const entry of entries) { const srcPath = path.join(srcDir, entry.name) const dstPath = path.join(dstDir, entry.name) if (entry.isDirectory()) { fs.mkdirSync(dstPath, { recursive: true }) count = copyDir(srcPath, dstPath, count) } else { count = count + 1 fs.copyFileSync(srcPath, dstPath) } } return count } function rmDir(dirPath: string, maxRetries: number = 5): void { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { fs.rmSync(dirPath, { recursive: true, force: true }) return } catch (error) { const isLastAttempt = attempt === maxRetries const isRetryableError = error instanceof Error && "code" in error && (error.code === "ENOTEMPTY" || error.code === "EBUSY" || error.code === "EPERM" || error.code === "EACCES") if (isLastAttempt) { // On the last attempt, try alternative cleanup methods. try { console.warn(`[rmDir] Final attempt using alternative cleanup for ${dirPath}`) // Try to clear readonly flags on Windows. if (process.platform === "win32") { try { execSync(`attrib -R "${dirPath}\\*.*" /S /D`, { stdio: "ignore" }) } catch { // Ignore attrib errors. } } fs.rmSync(dirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }) return } catch (finalError) { console.error(`[rmDir] Failed to remove ${dirPath} after ${maxRetries} attempts:`, finalError) throw finalError } } if (!isRetryableError) { throw error // Re-throw if it's not a retryable error. } // Wait with exponential backoff before retrying, with longer delays for Windows. const baseDelay = process.platform === "win32" ? 200 : 100 const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), 2000) // Cap at 2s console.warn(`[rmDir] Attempt ${attempt} failed for ${dirPath}, retrying in ${delay}ms...`) // Synchronous sleep for simplicity in build scripts. const start = Date.now() while (Date.now() - start < delay) { /* Busy wait */ } } } } type CopyPathOptions = { optional?: boolean } export function copyPaths(copyPaths: [string, string, CopyPathOptions?][], srcDir: string, dstDir: string) { copyPaths.forEach(([srcRelPath, dstRelPath, options = {}]) => { try { const stats = fs.lstatSync(path.join(srcDir, srcRelPath)) if (stats.isDirectory()) { if (fs.existsSync(path.join(dstDir, dstRelPath))) { rmDir(path.join(dstDir, dstRelPath)) } fs.mkdirSync(path.join(dstDir, dstRelPath), { recursive: true }) const count = copyDir(path.join(srcDir, srcRelPath), path.join(dstDir, dstRelPath), 0) console.log(`[copyPaths] Copied ${count} files from ${srcRelPath} to ${dstRelPath}`) } else { fs.copyFileSync(path.join(srcDir, srcRelPath), path.join(dstDir, dstRelPath)) console.log(`[copyPaths] Copied ${srcRelPath} to ${dstRelPath}`) } } catch (error) { if (options.optional) { console.warn(`[copyPaths] Optional file not found: ${srcRelPath}`) } else { throw error } } }) } export function copyWasms(srcDir: string, distDir: string): void { const nodeModulesDir = path.join(srcDir, "node_modules") fs.mkdirSync(distDir, { recursive: true }) // Tiktoken WASM file. fs.copyFileSync( path.join(nodeModulesDir, "tiktoken", "lite", "tiktoken_bg.wasm"), path.join(distDir, "tiktoken_bg.wasm"), ) console.log(`[copyWasms] Copied tiktoken WASMs to ${distDir}`) // Also copy Tiktoken WASMs to the workers directory. const workersDir = path.join(distDir, "workers") fs.mkdirSync(workersDir, { recursive: true }) fs.copyFileSync( path.join(nodeModulesDir, "tiktoken", "lite", "tiktoken_bg.wasm"), path.join(workersDir, "tiktoken_bg.wasm"), ) console.log(`[copyWasms] Copied tiktoken WASMs to ${workersDir}`) // Main tree-sitter WASM file. fs.copyFileSync( path.join(nodeModulesDir, "web-tree-sitter", "tree-sitter.wasm"), path.join(distDir, "tree-sitter.wasm"), ) console.log(`[copyWasms] Copied tree-sitter.wasm to ${distDir}`) // Copy language-specific WASM files. const languageWasmDir = path.join(nodeModulesDir, "tree-sitter-wasms", "out") if (!fs.existsSync(languageWasmDir)) { throw new Error(`Directory does not exist: ${languageWasmDir}`) } // Dynamically read all WASM files from the directory instead of using a hardcoded list. const wasmFiles = fs.readdirSync(languageWasmDir).filter((file) => file.endsWith(".wasm")) wasmFiles.forEach((filename) => { fs.copyFileSync(path.join(languageWasmDir, filename), path.join(distDir, filename)) }) 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 { const destDir = path.join(distDir, "i18n", "locales") fs.mkdirSync(destDir, { recursive: true }) const count = copyDir(path.join(srcDir, "i18n", "locales"), destDir, 0) console.log(`[copyLocales] Copied ${count} locale files to ${destDir}`) } export function setupLocaleWatcher(srcDir: string, distDir: string) { const localesDir = path.join(srcDir, "i18n", "locales") if (!fs.existsSync(localesDir)) { console.warn(`Cannot set up watcher: Source locales directory does not exist: ${localesDir}`) return } console.log(`Setting up watcher for locale files in ${localesDir}`) let debounceTimer: NodeJS.Timeout | null = null const debouncedCopy = () => { if (debounceTimer) { clearTimeout(debounceTimer) } // Wait 300ms after last change before copying. debounceTimer = setTimeout(() => { console.log("Locale files changed, copying...") copyLocales(srcDir, distDir) }, 300) } try { fs.watch(localesDir, { recursive: true }, (_eventType, filename) => { if (filename && filename.endsWith(".json")) { console.log(`Locale file ${filename} changed, triggering copy...`) debouncedCopy() } }) console.log("Watcher for locale files is set up") } catch (error) { console.error( `Error setting up watcher for ${localesDir}:`, error instanceof Error ? error.message : "Unknown error", ) } } export function generatePackageJson({ packageJson: { contributes, ...packageJson }, overrideJson, substitution, }: { packageJson: Record // eslint-disable-line @typescript-eslint/no-explicit-any overrideJson: Record // eslint-disable-line @typescript-eslint/no-explicit-any substitution: [string, string] }) { const { viewsContainers, views, commands, menus, submenus, keybindings, configuration } = contributesSchema.parse(contributes) const [from, to] = substitution // eslint-disable-next-line @typescript-eslint/no-explicit-any const result: Record = { ...packageJson, ...overrideJson, contributes: { viewsContainers: transformArrayRecord(viewsContainers, from, to, ["id"]), views: transformArrayRecord(views, from, to, ["id"]), commands: transformArray(commands, from, to, "command"), menus: transformArrayRecord(menus, from, to, ["command", "submenu", "when"]), submenus: transformArray(submenus, from, to, "id"), configuration: { title: configuration.title, properties: transformRecord(configuration.properties, from, to), }, }, } // Only add keybindings if they exist if (keybindings) { result.contributes.keybindings = transformArray(keybindings, from, to, "command") } return result } // eslint-disable-next-line @typescript-eslint/no-explicit-any function transformArrayRecord(obj: Record, from: string, to: string, props: string[]): T { return Object.entries(obj).reduce( (acc, [key, ary]) => ({ ...acc, [key.replaceAll(from, to)]: ary.map((item) => { const transformedItem = { ...item } for (const prop of props) { if (prop in item && typeof item[prop] === "string") { transformedItem[prop] = item[prop].replaceAll(from, to) } } return transformedItem }), }), {} as T, ) } // eslint-disable-next-line @typescript-eslint/no-explicit-any function transformArray(arr: any[], from: string, to: string, idProp: string): T[] { return arr.map(({ [idProp]: id, ...rest }) => ({ [idProp]: id.replaceAll(from, to), ...rest, })) } // eslint-disable-next-line @typescript-eslint/no-explicit-any function transformRecord(obj: Record, from: string, to: string): T { return Object.entries(obj).reduce( (acc, [key, value]) => ({ ...acc, [key.replaceAll(from, to)]: value, }), {} as T, ) }