#!/usr/bin/env node // One-shot CJS → ESM conversion script for the box codebase. // Usage: // node scripts/cjs-to-esm.mjs # convert all src/ + syslog.js // node scripts/cjs-to-esm.mjs --dry-run # preview without writing // node scripts/cjs-to-esm.mjs src/paths.js # convert specific file(s) import fs from 'node:fs'; import path from 'node:path'; const ROOT = process.cwd(); const DRY_RUN = process.argv.includes('--dry-run'); const args = process.argv.slice(2).filter(a => !a.startsWith('--')); // ========== File Collection ========== function walk(dir) { const result = []; for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); if (e.isDirectory()) result.push(...walk(p)); else if (e.name.endsWith('.js')) result.push(p); } return result; } function getFiles() { if (args.length) return args.map(f => path.resolve(f)); const files = walk(path.join(ROOT, 'src')); files.push(path.join(ROOT, 'syslog.js')); return files; } // ========== NPM Package ESM Detection ========== const esmCache = {}; function isEsmPkg(modPath) { const name = modPath.startsWith('@') ? modPath.split('/').slice(0, 2).join('/') : modPath.split('/')[0]; if (name in esmCache) return esmCache[name]; try { const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'node_modules', name, 'package.json'), 'utf-8')); esmCache[name] = pkg.type === 'module'; } catch { esmCache[name] = false; } return esmCache[name]; } // ========== Import Path Fixup ========== function fixPath(mod, fromFile) { if (!mod.startsWith('.')) return mod; if (mod.endsWith('.js') || mod.endsWith('.mjs')) return mod; const dir = path.dirname(fromFile); const resolved = path.resolve(dir, mod); if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) return mod + '/index.js'; if (fs.existsSync(resolved + '.js')) return mod + '.js'; return mod; } // ========== Convert a Single Require Declaration ========== // Input: trimmed declaration text WITHOUT leading const/let/var and trailing ,; // Returns { imports: string[], consts: string[] } or null function convertDecl(decl, fromFile) { decl = decl.trim().replace(/[,;]\s*$/, '').trim(); if (!decl || !decl.includes('require(')) return null; let m; // 1) name = require('mod')('args...') e.g. debug = require('debug')('box:dns') m = decl.match(/^(\w+)\s*=\s*require\(['"]([^'"]+)['"]\)\((.+)\)$/); if (m) { const [, name, mod, callArgs] = m; const alias = name + 'Module'; return { imports: [`import ${alias} from '${fixPath(mod, fromFile)}';`], consts: [`const ${name} = ${alias}(${callArgs});`] }; } // 2) name = require('mod').prop e.g. HttpError = require('@cloudron/connect-lastmile').HttpError m = decl.match(/^(\w+)\s*=\s*require\(['"]([^'"]+)['"]\)\.(\w+)$/); if (m) { const [, name, mod, prop] = m; const fp = fixPath(mod, fromFile); const imp = name === prop ? `import { ${prop} } from '${fp}';` : `import { ${prop} as ${name} } from '${fp}';`; return { imports: [imp], consts: [] }; } // 3) { a, b } = require('mod') e.g. { CronTime } = require('cron') m = decl.match(/^(\{[^}]+\})\s*=\s*require\(['"]([^'"]+)['"]\)$/); if (m) { const [, destr, mod] = m; return { imports: [`import ${destr} from '${fixPath(mod, fromFile)}';`], consts: [] }; } // 4) name = require('mod') e.g. apps = require('./apps.js') m = decl.match(/^(\w+)\s*=\s*require\(['"]([^'"]+)['"]\)$/); if (m) { const [, name, mod] = m; const fp = fixPath(mod, fromFile); // Use namespace import for ESM-only npm packages (they likely have no default export) const useNamespace = !mod.startsWith('.') && !mod.startsWith('node:') && isEsmPkg(mod); const imp = useNamespace ? `import * as ${name} from '${fp}';` : `import ${name} from '${fp}';`; return { imports: [imp], consts: [] }; } return null; // unknown pattern } // ========== Process a const-require Block ========== function handleBlock(blockLines, imports, consts, warnings, fromFile) { const block = blockLines.join('\n'); // Strip leading const/let/var and trailing ; let inner = block.replace(/^\s*(const|let|var)\s+/, ''); inner = inner.replace(/;\s*$/, ''); // Split individual declarations by comma-at-end-of-line const decls = inner.split(/,\n/).map(d => d.trim()); for (const decl of decls) { if (!decl) continue; if (!decl.includes('require(')) { // Non-require declaration in a mixed block — keep as const consts.push(`const ${decl};`); continue; } const result = convertDecl(decl, fromFile); if (result) { imports.push(...result.imports); if (result.consts.length) consts.push(...result.consts); } else { warnings.push(`Unknown require pattern: ${decl}`); consts.push(`const ${decl};`); } } } // ========== Convert Exports Block ========== function convertExports(block) { // exports = module.exports = { ... }; let m = block.match(/exports\s*=\s*module\.exports\s*=\s*(\{[\s\S]*\});/); if (m) return `export default ${m[1]};`; // exports = module.exports = name; m = block.match(/exports\s*=\s*module\.exports\s*=\s*(\w+)\s*;/); if (m) return `export default ${m[1]};`; return block; // fallback } // ========== Main File Processor ========== function processFile(filePath) { const lines = fs.readFileSync(filePath, 'utf-8').split('\n'); const imports = []; const consts = []; const output = []; const warnings = []; let i = 0; let inBlock = false; let blockLines = []; while (i < lines.length) { const line = lines[i]; const t = line.trim(); // --- Skip 'use strict' --- if (t === "'use strict';" || t === '"use strict";') { i++; if (i < lines.length && lines[i].trim() === '') i++; continue; } // --- Detect start of const/let/var block --- if (!inBlock && /^\s*(const|let|var)\s+/.test(line)) { blockLines = [line]; if (t.endsWith(';')) { // Single-line block if (/require\(/.test(line)) { handleBlock(blockLines, imports, consts, warnings, filePath); } else { output.push(line); } } else { inBlock = true; } i++; continue; } // --- Continue multi-line const block --- if (inBlock) { blockLines.push(line); if (t.endsWith(';')) { const fullBlock = blockLines.join('\n'); if (/require\(/.test(fullBlock)) { handleBlock(blockLines, imports, consts, warnings, filePath); } else { output.push(...blockLines); } inBlock = false; } i++; continue; } // --- Exports block --- if (/^\s*exports\s*=\s*module\.exports\s*=/.test(line)) { let block = line; if (!t.endsWith(';')) { i++; while (i < lines.length) { block += '\n' + lines[i]; if (lines[i].trim().endsWith(';')) { i++; break; } i++; } } else { i++; } if (/require\(/.test(block)) { warnings.push('Inline require() in exports block — needs manual fix'); output.push(block); } else { output.push(convertExports(block)); } continue; } // --- require.main === module --- if (/require\.main\s*===\s*module/.test(t)) { output.push(line.replace(/require\.main\s*===\s*module/, 'process.argv[1] === import.meta.filename')); i++; continue; } // --- Warn about any remaining require() calls --- if (/\brequire\s*\(/.test(t) && !/^\s*\/\//.test(line) && !/^\s*\*/.test(line)) { warnings.push(`Line ${i + 1}: Unconverted require(): ${t}`); } output.push(line); i++; } // --- Replace __dirname / __filename with import.meta equivalents (Node 21.2+) --- for (let j = 0; j < output.length; j++) { output[j] = output[j].replace(/\b__dirname\b/g, 'import.meta.dirname'); output[j] = output[j].replace(/\b__filename\b/g, 'import.meta.filename'); } // --- Reconstruct file --- // Preserve shebang and leading block comments before imports const leading = []; let start = 0; // Shebang if (output.length > 0 && output[0].trimStart().startsWith('#!')) { leading.push(output[0]); start = 1; // skip blank line after shebang while (start < output.length && output[start].trim() === '') start++; } // Leading block comment (/* jslint */, /* global */, /* eslint */) if (start < output.length && output[start].trim().startsWith('/*')) { // Could be single-line or multi-line if (output[start].includes('*/')) { leading.push(output[start]); start++; } else { while (start < output.length) { leading.push(output[start]); if (output[start].includes('*/')) { start++; break; } start++; } } // skip blank line after comment if (start < output.length && output[start].trim() === '') start++; } const rest = output.slice(start); // Remove leading blank lines from rest while (rest.length > 0 && rest[0].trim() === '') rest.shift(); // Build final content const parts = []; if (leading.length > 0) { parts.push(...leading); parts.push(''); } if (imports.length > 0) { parts.push(...imports); } if (consts.length > 0) { if (imports.length > 0) parts.push(''); parts.push(...consts); } if (imports.length > 0 || consts.length > 0) { parts.push(''); } parts.push(...rest); let result = parts.join('\n'); // Clean up 3+ consecutive blank lines → 2 result = result.replace(/\n{3,}/g, '\n\n'); // Ensure trailing newline if (!result.endsWith('\n')) result += '\n'; return { content: result, warnings }; } // ========== Run ========== const files = getFiles(); console.log(`Processing ${files.length} files${DRY_RUN ? ' (dry run)' : ''}...\n`); let totalWarnings = 0; for (const file of files) { const rel = path.relative(ROOT, file); try { const { content, warnings } = processFile(file); if (warnings.length > 0) { console.log(`⚠ ${rel}:`); for (const w of warnings) console.log(` ${w}`); totalWarnings += warnings.length; } if (!DRY_RUN) { fs.writeFileSync(file, content); console.log(`✓ ${rel}`); } else { console.log(` ${rel}${warnings.length ? '' : ' (ok)'}`); } } catch (err) { console.error(`✗ ${rel}: ${err.message}`); totalWarnings++; } } console.log(`\nDone. ${files.length} files processed, ${totalWarnings} warnings.`); if (totalWarnings > 0) console.log('Review warnings above and fix manually.');