Files
cloudron-box/scripts/cjs-to-esm/cjs-to-esm.mjs
T
Girish Ramakrishnan 96dc79cfe6 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>
2026-02-14 15:11:45 +01:00

356 lines
12 KiB
JavaScript

#!/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.');