Migrate codebase from CommonJS to ES Modules
- Convert all require()/module.exports to import/export across 260+ files - Add "type": "module" to package.json to enable ESM by default - Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible - Convert eslint.config.js to ESM with sourceType: "module" - Replace __dirname/__filename with import.meta.dirname/import.meta.filename - Replace require.main === module with process.argv[1] === import.meta.filename - Remove 'use strict' directives (implicit in ESM) - Convert dynamic require() in switch statements to static import lookup maps (dns.js, domains.js, backupformats.js, backupsites.js, network.js) - Extract self-referencing exports.CONSTANT patterns into standalone const declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.) - Lazify SERVICES object in services.js to avoid circular dependency TDZ issues - Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests - Add _setMockApp() to ldapserver.js for ESM-safe test mocking - Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests - Convert backupupload.js to use dynamic imports so --check exits before loading the module graph (which requires BOX_ENV) - Update check-install to use ESM import for infra_version.js - Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations) - All 1315 tests passing Migration stats (AI-assisted using Cursor with Claude): - Wall clock time: ~3-4 hours - Assistant completions: ~80-100 - Estimated token usage: ~1-2M tokens Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
#!/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.');
|
||||
Reference in New Issue
Block a user