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:
Girish Ramakrishnan
2026-02-14 09:53:14 +01:00
parent e0e9f14a5e
commit 96dc79cfe6
277 changed files with 4789 additions and 3811 deletions
@@ -0,0 +1,96 @@
#!/usr/bin/env node
// Check for import/export mismatches:
// 1. Files using `import X from './file.js'` where file.js has no default export
// 2. Files using `import * as X from './file.js'` where file.js ONLY has default export
import fs from 'node:fs';
import path from 'node:path';
const ROOT = process.cwd();
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;
}
const srcFiles = walk(path.join(ROOT, 'src'));
srcFiles.push(path.join(ROOT, 'syslog.js'));
// Build export map
const exportMap = {}; // file -> 'default' | 'named' | 'both' | 'none'
for (const file of srcFiles) {
const content = fs.readFileSync(file, 'utf-8');
const hasDefault = /^export default /m.test(content);
const hasNamed = /^export \{/m.test(content) || /^export (const|let|var|function|class) /m.test(content);
if (hasDefault && hasNamed) exportMap[file] = 'both';
else if (hasDefault) exportMap[file] = 'default';
else if (hasNamed) exportMap[file] = 'named';
else exportMap[file] = 'none';
}
// Check imports
let mismatches = 0;
const fixes = []; // { file, line, from, to }
for (const file of srcFiles) {
const content = fs.readFileSync(file, 'utf-8');
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Default import of local file
let m = line.match(/^import\s+(\w+)\s+from\s+'(\.[^']+\.js)';$/);
if (m) {
const [, name, importPath] = m;
const resolved = path.resolve(path.dirname(file), importPath);
const exportType = exportMap[resolved];
if (exportType === 'named') {
console.log(`MISMATCH: ${path.relative(ROOT, file)}:${i+1} - import ${name} from '${importPath}' but ${path.relative(ROOT, resolved)} has only named exports`);
fixes.push({ file, lineNum: i, old: line, new: line.replace(`import ${name}`, `import * as ${name}`) });
mismatches++;
}
}
// Namespace import of local file
m = line.match(/^import\s+\*\s+as\s+(\w+)\s+from\s+'(\.[^']+\.js)';$/);
if (m) {
const [, name, importPath] = m;
const resolved = path.resolve(path.dirname(file), importPath);
const exportType = exportMap[resolved];
if (exportType === 'default') {
console.log(`MISMATCH: ${path.relative(ROOT, file)}:${i+1} - import * as ${name} from '${importPath}' but ${path.relative(ROOT, resolved)} has only default export`);
fixes.push({ file, lineNum: i, old: line, new: line.replace(`import * as ${name}`, `import ${name}`) });
mismatches++;
}
}
}
}
console.log(`\nFound ${mismatches} mismatches.`);
if (process.argv.includes('--fix') && fixes.length > 0) {
// Group by file
const byFile = {};
for (const fix of fixes) {
if (!byFile[fix.file]) byFile[fix.file] = [];
byFile[fix.file].push(fix);
}
for (const [file, fileFixes] of Object.entries(byFile)) {
let content = fs.readFileSync(file, 'utf-8');
for (const fix of fileFixes) {
content = content.replace(fix.old, fix.new);
}
fs.writeFileSync(file, content);
console.log(`Fixed: ${path.relative(ROOT, file)} (${fileFixes.length} changes)`);
}
console.log('All mismatches fixed.');
}
+355
View File
@@ -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.');
@@ -0,0 +1,66 @@
#!/usr/bin/env node
// Fix consumers of files that still use export default { ... }
// Change `import * as X from './file.js'` back to `import X from './file.js'`
// for files whose target still uses export default.
import fs from 'node:fs';
import path from 'node:path';
const ROOT = process.cwd();
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;
}
const srcFiles = walk(path.join(ROOT, 'src'));
srcFiles.push(path.join(ROOT, 'syslog.js'));
// Find files that use export default (not named exports)
const defaultExportFiles = new Set();
for (const file of srcFiles) {
const content = fs.readFileSync(file, 'utf-8');
if (/^export default /m.test(content)) {
defaultExportFiles.add(file);
}
}
console.log(`Files with export default: ${defaultExportFiles.size}`);
for (const f of defaultExportFiles) {
console.log(` ${path.relative(ROOT, f)}`);
}
// Fix consumers: change `import * as X from './file.js'` → `import X from './file.js'`
// for files that use export default
let changes = 0;
for (const file of srcFiles) {
let content = fs.readFileSync(file, 'utf-8');
let changed = false;
const importRegex = /^(import\s+)\* as (\w+)(\s+from\s+')(\.[^']+\.js)(';)$/gm;
const newContent = content.replace(importRegex, (match, pre, name, mid, importPath, post) => {
const dir = path.dirname(file);
const resolved = path.resolve(dir, importPath);
if (defaultExportFiles.has(resolved)) {
changed = true;
changes++;
return `${pre}${name}${mid}${importPath}${post}`;
}
return match;
});
if (changed) {
fs.writeFileSync(file, newContent);
console.log(`✓ Fixed imports: ${path.relative(ROOT, file)}`);
}
}
console.log(`\nReverted ${changes} imports to default import style.`);
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env node
// Fix `exports.CONSTANT` self-references in ESM files.
// For files with `export default { ... }`:
// - Extract constant properties (simple key: 'literal' pairs) as standalone const declarations
// - Replace the inline definition in the export block with shorthand property
// - Replace `exports.CONSTANT` with `CONSTANT` everywhere
// For files with `export { ... }` (constants already standalone):
// - Just replace `exports.CONSTANT` with `CONSTANT`
import fs from 'node:fs';
import path from 'node:path';
const ROOT = path.resolve(import.meta.dirname, '..');
const args = process.argv.slice(2).filter(a => !a.startsWith('--'));
function findFilesWithExportsRef(dir) {
const results = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'migrations' || entry.name === 'dashboard') continue;
results.push(...findFilesWithExportsRef(full));
} else if (entry.name.endsWith('.js')) {
const content = fs.readFileSync(full, 'utf8');
if (/\bexports\./.test(content)) {
results.push(full);
}
}
}
return results;
}
function processFile(filePath) {
let content = fs.readFileSync(filePath, 'utf8');
const relPath = path.relative(ROOT, filePath);
// Find all exports.SOMETHING references
const exportsRefs = new Set();
const re = /\bexports\.(\w+)/g;
let m;
while ((m = re.exec(content)) !== null) {
exportsRefs.add(m[1]);
}
if (exportsRefs.size === 0) return;
console.log(`\n=== ${relPath} ===`);
console.log(` Self-referenced constants: ${[...exportsRefs].join(', ')}`);
// Check if these constants already exist as standalone const/let/var declarations
const alreadyDeclared = new Set();
for (const name of exportsRefs) {
const declPattern = new RegExp(`^(?:const|let|var)\\s+${name}\\s*=`, 'm');
if (declPattern.test(content)) {
alreadyDeclared.add(name);
}
}
const needsExtraction = [...exportsRefs].filter(n => !alreadyDeclared.has(n));
if (alreadyDeclared.size > 0) {
console.log(` Already declared: ${[...alreadyDeclared].join(', ')}`);
}
// For constants that need extraction, find them in the export default block
if (needsExtraction.length > 0) {
console.log(` Need extraction: ${needsExtraction.join(', ')}`);
// Find export default { ... } block
// Parse it carefully - we need to handle the block properly
const exportStart = content.indexOf('export default {');
if (exportStart === -1) {
console.log(` WARNING: No 'export default {' found but need extraction for: ${needsExtraction.join(', ')}`);
// Just do the replacement anyway
} else {
// Find the matching closing brace
let braceDepth = 0;
let exportEnd = -1;
let inString = false;
let stringChar = '';
for (let i = exportStart + 'export default '.length; i < content.length; i++) {
const ch = content[i];
if (inString) {
if (ch === '\\') { i++; continue; }
if (ch === stringChar) inString = false;
continue;
}
if (ch === '\'' || ch === '"' || ch === '`') {
inString = true;
stringChar = ch;
continue;
}
if (ch === '{') braceDepth++;
else if (ch === '}') {
braceDepth--;
if (braceDepth === 0) {
exportEnd = i + 1;
// Include trailing semicolon
if (content[exportEnd] === ';') exportEnd++;
break;
}
}
}
if (exportEnd === -1) {
console.log(` WARNING: Could not find end of export default block`);
} else {
const exportBlock = content.slice(exportStart, exportEnd);
const lines = exportBlock.split('\n');
const extracted = []; // { name, value, originalLine }
const newLines = [];
for (const line of lines) {
const trimmed = line.trim();
let found = false;
for (const name of needsExtraction) {
// Match: NAME: 'value', or NAME: 123, or NAME: true,
// Only match simple literal values (strings, numbers, booleans)
const pattern = new RegExp(`^${name}:\\s*('[^']*'|"[^"]*"|\\d+(?:\\.\\d+)?|true|false|null),?\\s*(?:\\/\\/.*)?$`);
const match = trimmed.match(pattern);
if (match) {
extracted.push({ name, value: match[1] });
// Replace the line with just the shorthand property
const indent = line.match(/^(\s*)/)[1];
newLines.push(`${indent}${name},`);
found = true;
break;
}
}
if (!found) {
newLines.push(line);
}
}
if (extracted.length > 0) {
const constDecls = extracted.map(e => `const ${e.name} = ${e.value};`).join('\n');
const newExportBlock = newLines.join('\n');
content = content.slice(0, exportStart) + constDecls + '\n\n' + newExportBlock + content.slice(exportEnd);
console.log(` Extracted ${extracted.length} constants: ${extracted.map(e => e.name).join(', ')}`);
}
const notExtracted = needsExtraction.filter(n => !extracted.find(e => e.name === n));
if (notExtracted.length > 0) {
console.log(` WARNING: Could not extract (complex values?): ${notExtracted.join(', ')}`);
}
}
}
}
// Replace all exports.CONSTANT references with just CONSTANT
for (const name of exportsRefs) {
const pattern = new RegExp(`\\bexports\\.${name}\\b`, 'g');
content = content.replace(pattern, name);
}
fs.writeFileSync(filePath, content);
console.log(` Done!`);
}
let files;
if (args.length) {
files = args.map(f => path.resolve(f));
} else {
files = findFilesWithExportsRef(path.join(ROOT, 'src'));
}
console.log(`Processing ${files.length} files...`);
for (const file of files) {
processFile(file);
}
console.log('\nAll done!');
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env node
// Phase 2: Convert export default { shorthand } → export { shorthand }
// and update consumers from `import X from` → `import * as X from` for local files.
import fs from 'node:fs';
import path from 'node:path';
const ROOT = process.cwd();
const DRY_RUN = process.argv.includes('--dry-run');
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;
}
const srcFiles = walk(path.join(ROOT, 'src'));
srcFiles.push(path.join(ROOT, 'syslog.js'));
// Phase 1: Identify files with shorthand-only export default { ... }
// and convert them to export { ... }
const convertedFiles = new Set();
for (const file of srcFiles) {
let content = fs.readFileSync(file, 'utf-8');
// Match export default { ... };
const match = content.match(/^export default (\{[\s\S]*?\});/m);
if (!match) continue;
const objContent = match[1];
// Check if this is shorthand-only (no "key: value" patterns)
// Shorthand entries look like: " funcName," or " funcName" (last one)
// Key-value entries look like: " KEY: value," or " key: value"
// We strip the braces and check each line
const inner = objContent.slice(1, -1); // remove { and }
const lines = inner.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('//'));
// Check if any line has a colon that's NOT in a comment
const hasKeyValue = lines.some(line => {
// Remove trailing comma
const cleaned = line.replace(/,\s*$/, '').trim();
if (!cleaned) return false;
// A shorthand property is just an identifier (possibly with leading/trailing whitespace)
// A key-value has "identifier: something" or "'key': something"
return cleaned.includes(':');
});
if (hasKeyValue) {
// Cannot convert to named exports - has computed values
continue;
}
// Convert export default { ... } → export { ... }
const newContent = content.replace(/^export default (\{[\s\S]*?\});/m, 'export $1;');
if (newContent !== content) {
if (!DRY_RUN) fs.writeFileSync(file, newContent);
convertedFiles.add(file);
console.log(`✓ Converted exports: ${path.relative(ROOT, file)}`);
}
}
console.log(`\nConverted ${convertedFiles.size} files to named exports.`);
// Phase 2: Update consumers
// For each converted file, find all files that import it with `import X from './path'`
// and change to `import * as X from './path'`
let importChanges = 0;
for (const file of srcFiles) {
let content = fs.readFileSync(file, 'utf-8');
let changed = false;
// Match: import identifier from './relative/path.js';
// or: import identifier from '../relative/path.js';
const importRegex = /^(import\s+)(\w+)(\s+from\s+')(\.[^']+\.js)(';)$/gm;
const newContent = content.replace(importRegex, (match, pre, name, mid, importPath, post) => {
// Resolve the import path to absolute
const dir = path.dirname(file);
const resolved = path.resolve(dir, importPath);
if (convertedFiles.has(resolved)) {
changed = true;
importChanges++;
return `${pre}* as ${name}${mid}${importPath}${post}`;
}
return match;
});
if (changed && !DRY_RUN) {
fs.writeFileSync(file, newContent);
console.log(`✓ Updated imports: ${path.relative(ROOT, file)}`);
}
}
console.log(`\nUpdated ${importChanges} import statements.`);
console.log('Done.');
@@ -0,0 +1,66 @@
#!/usr/bin/env node
// For a set of target files, find imports that use `import X from './module.js'`
// where the module uses `export { ... }` (named exports, no default).
// Convert those to `import * as X from './module.js'`.
import fs from 'node:fs';
import path from 'node:path';
const ROOT = path.resolve(import.meta.dirname, '..');
const targetFiles = process.argv.slice(2).map(f => path.resolve(f));
if (targetFiles.length === 0) {
console.log('Usage: node scripts/fix-imports-for-named.mjs file1.js file2.js ...');
process.exit(1);
}
// Build a set of files that use named exports (export { ... }) without a default export
function hasOnlyNamedExports(filePath) {
if (!fs.existsSync(filePath)) return false;
const content = fs.readFileSync(filePath, 'utf8');
const hasNamedExport = /^export \{/m.test(content);
const hasDefaultExport = /^export default /m.test(content);
return hasNamedExport && !hasDefaultExport;
}
for (const filePath of targetFiles) {
let content = fs.readFileSync(filePath, 'utf8');
const relPath = path.relative(ROOT, filePath);
const dir = path.dirname(filePath);
let changes = 0;
// Find all default imports: import NAME from './something.js'
const importRe = /^import (\w+) from '([^']+)';$/gm;
let match;
const replacements = [];
while ((match = importRe.exec(content)) !== null) {
const [fullMatch, name, specifier] = match;
// Only handle local/relative imports
if (!specifier.startsWith('.')) continue;
// Resolve the full path
const resolved = path.resolve(dir, specifier);
if (hasOnlyNamedExports(resolved)) {
replacements.push({
from: fullMatch,
to: `import * as ${name} from '${specifier}';`
});
}
}
for (const r of replacements) {
content = content.replace(r.from, r.to);
changes++;
}
if (changes > 0) {
fs.writeFileSync(filePath, content);
console.log(`${relPath}: fixed ${changes} imports`);
} else {
console.log(`${relPath}: no changes needed`);
}
}
@@ -0,0 +1,129 @@
#!/usr/bin/env node
// Phase 3: Convert remaining export default { ... } (with computed properties)
// to named exports by extracting computed values as const declarations.
// Also updates consumers from `import X from` → `import * as X from`.
import fs from 'node:fs';
import path from 'node:path';
const ROOT = process.cwd();
const DRY_RUN = process.argv.includes('--dry-run');
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;
}
const srcFiles = walk(path.join(ROOT, 'src'));
srcFiles.push(path.join(ROOT, 'syslog.js'));
const convertedFiles = new Set();
for (const file of srcFiles) {
let content = fs.readFileSync(file, 'utf-8');
// Match export default { ... };
const match = content.match(/^(export default )(\{[\s\S]*?\});/m);
if (!match) continue;
const fullMatch = match[0];
const objContent = match[2];
const inner = objContent.slice(1, -1); // strip { }
// Parse entries
const lines = inner.split('\n');
const extractedConsts = [];
const exportNames = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith('//') || line.startsWith('/*') || line.startsWith('*')) continue;
// Remove trailing comma
const cleaned = line.replace(/,\s*$/, '').trim();
if (!cleaned) continue;
// Check if it's a key-value pair (has : that's not in a string or ternary)
// Simple heuristic: if the line matches "identifier: expression"
const kvMatch = cleaned.match(/^(\w+)\s*:\s*(.+)$/);
if (kvMatch) {
const [, key, value] = kvMatch;
// Check if this is truly a key-value or just a shorthand with same name
// If key === value, it's shorthand (e.g. "x: x" is rare but possible)
if (key !== value) {
extractedConsts.push(`const ${key} = ${value};`);
exportNames.push(key);
continue;
}
}
// Shorthand property
exportNames.push(cleaned);
}
// Build new export block
const newExport = `export {\n ${exportNames.join(',\n ')},\n};`;
// Build replacement: extracted consts + newline + export block
let replacement;
if (extractedConsts.length > 0) {
replacement = extractedConsts.join('\n') + '\n\n' + newExport;
} else {
replacement = newExport;
}
const newContent = content.replace(fullMatch, replacement);
if (newContent !== content) {
if (!DRY_RUN) fs.writeFileSync(file, newContent);
convertedFiles.add(file);
console.log(`✓ Converted exports: ${path.relative(ROOT, file)}`);
}
}
console.log(`\nConverted ${convertedFiles.size} files.`);
// Phase 2: Update consumers
let importChanges = 0;
for (const file of srcFiles) {
let content = fs.readFileSync(file, 'utf-8');
let changed = false;
const importRegex = /^(import\s+)(\w+)(\s+from\s+')(\.[^']+\.js)(';)$/gm;
const newContent = content.replace(importRegex, (match, pre, name, mid, importPath, post) => {
const dir = path.dirname(file);
const resolved = path.resolve(dir, importPath);
if (convertedFiles.has(resolved)) {
changed = true;
importChanges++;
return `${pre}* as ${name}${mid}${importPath}${post}`;
}
return match;
});
if (changed && !DRY_RUN) {
fs.writeFileSync(file, newContent);
console.log(`✓ Updated imports: ${path.relative(ROOT, file)}`);
}
}
console.log(`\nUpdated ${importChanges} import statements.`);
// Verify no more export default { ... } remain
let remaining = 0;
for (const file of srcFiles) {
const content = fs.readFileSync(file, 'utf-8');
if (/^export default \{/m.test(content)) {
console.log(`⚠ Still has export default: ${path.relative(ROOT, file)}`);
remaining++;
}
}
if (remaining > 0) console.log(`\n${remaining} files still need manual attention.`);
else console.log('\nAll export default { ... } converted to named exports.');
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env node
// Revert the broken extraction from fix-remaining-exports.mjs
// and instead use a proper approach:
// 1. Keep export default { ... } for files with computed properties
// 2. Change all `import X from './local.js'` for those files to
// `import * as _X from './local.js'; const X = _X.default;`
// Actually that's ugly. Better approach:
// Just lazify the top-level constant evaluation in services.js
// OR use a re-export pattern.
//
// Actually, the simplest approach: restore the 34 files from git,
// then re-run the first two conversion scripts on them,
// then for files with complex exports, add a thin named-export wrapper.
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
const ROOT = process.cwd();
// Find files that still have export default but with broken syntax
const files = execSync('git diff --name-only', { cwd: ROOT, encoding: 'utf-8' })
.trim().split('\n')
.filter(f => f.startsWith('src/') || f === 'syslog.js');
// Check which files have syntax errors
const brokenFiles = [];
for (const file of files) {
try {
execSync(`node --check ${file}`, { cwd: ROOT, stdio: 'pipe' });
} catch {
brokenFiles.push(file);
}
}
console.log(`Found ${brokenFiles.length} files with syntax errors:`);
brokenFiles.forEach(f => console.log(` ${f}`));
// Restore these files from git
for (const file of brokenFiles) {
execSync(`git checkout -- ${file}`, { cwd: ROOT });
console.log(`Restored: ${file}`);
}
// Now re-run the initial CJS→ESM conversion on just these files
console.log('\nRe-running CJS→ESM conversion on restored files...');
const fileArgs = brokenFiles.join(' ');
execSync(`node scripts/cjs-to-esm.mjs ${fileArgs}`, { cwd: ROOT, stdio: 'inherit' });
// Now re-run the first export fix (shorthand-only) on these files
// But we need a targeted approach: for each file, check if its exports
// are shorthand-only, and if so convert to named exports.
// Files with computed properties will keep export default.
console.log('\nRe-running export fixes...');
execSync('node scripts/fix-exports.mjs', { cwd: ROOT, stdio: 'inherit' });
console.log('\nDone. Files with complex exports will keep export default.');
console.log('Circular dependency issues will be fixed by lazifying top-level access.');