moyf_better-links/scripts/setup-eslint.mjs
2026-03-19 12:34:31 +08:00

791 lines
34 KiB
JavaScript

#!/usr/bin/env node
/**
* Setup ESLint for Obsidian Plugin
*
* This script:
* - Updates package.json with ESLint 9 devDependencies and scripts
* - Ensures TypeScript version is >=4.8.4 (required for ESLint compatibility)
* - Generates eslint.config.mjs (flat config) configuration file
* - Generates .pnpmrc configuration file (if needed)
* - Copies lint-wrapper.mjs for helpful linting success messages
*
* Usage: node scripts/setup-eslint.mjs
*/
import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
const ESLINT_DEPS = {
"@eslint/js": "^9.30.1",
"@eslint/json": "^0.14.0",
"@typescript-eslint/eslint-plugin": "^8.33.1",
"@typescript-eslint/parser": "^8.33.1",
"eslint": "^9.39.1",
"eslint-plugin-obsidianmd": "^0.1.9",
"globals": "^14.0.0",
"typescript-eslint": "^8.35.1"
};
const ESLINT_SCRIPTS = {
"lint": "node scripts/lint-wrapper.mjs",
"lint:fix": "node scripts/lint-wrapper.mjs --fix"
};
const MIN_TYPESCRIPT_VERSION = "^4.8.4";
function generateLintWrapper() {
return `#!/usr/bin/env node
/**
* ESLint wrapper that adds helpful success messages
*/
import { spawn, execSync } from 'child_process';
import process from 'process';
const args = process.argv.slice(2);
const hasFix = args.includes('--fix');
// Detect which package manager to use
// Check if pnpm is available, otherwise fall back to npx
let usePnpm = false;
try {
execSync('pnpm --version', { stdio: 'ignore', shell: true });
usePnpm = true;
} catch (error) {
usePnpm = false;
}
// Run ESLint with --max-warnings 0 to fail on warnings too
// This ensures we only show success when there are truly no issues
const eslintArgs = ['eslint', '.', '--max-warnings', '0', ...args];
const command = usePnpm ? 'pnpm' : 'npx';
const commandArgs = usePnpm ? ['exec', ...eslintArgs] : eslintArgs;
const eslint = spawn(command, commandArgs, {
stdio: 'inherit',
shell: false
});
eslint.on('close', (code) => {
if (code === 0) {
const message = hasFix
? '\\n✓ Linting complete! All issues fixed automatically.\\n'
: '\\n✓ Linting passed! No issues found.\\n';
console.log(message);
process.exit(0);
} else {
// ESLint already printed errors, just exit with the code
process.exit(code);
}
});
`;
}
function generateEslintConfig(customRules = {}) {
// Default custom rules (common overrides)
const defaultRules = {
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-empty-function": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-misused-promises": ["error", {
"checksVoidReturn": {
"attributes": false,
"properties": false,
"returns": false,
"variables": false
}
}],
// Console rules: Match Obsidian bot requirements (only warn/error/debug allowed)
"no-console": ["error", { "allow": ["warn", "error", "debug"] }],
// Require await in async functions (matches Obsidian bot)
"@typescript-eslint/require-await": "error"
};
// Merge custom rules with defaults (custom rules take precedence)
const rules = { ...defaultRules, ...customRules };
// Format rules as JavaScript object string
const rulesString = Object.entries(rules)
.map(([key, value]) => {
if (typeof value === 'string') {
return ` "${key}": "${value}"`;
} else {
const valueStr = JSON.stringify(value);
return ` "${key}": ${valueStr}`;
}
})
.join(',\n');
return `// eslint.config.mjs
import tsparser from "@typescript-eslint/parser";
import { defineConfig } from "eslint/config";
import obsidianmd from "eslint-plugin-obsidianmd";
import globals from "globals";
export default defineConfig([
{
ignores: ["main.js", "node_modules/**", "dist/**", "*.js", "scripts/**", ".ref/**"]
},
...obsidianmd.configs.recommended,
{
files: ["**/*.ts"],
languageOptions: {
parser: tsparser,
parserOptions: {
project: "./tsconfig.json",
sourceType: "module"
},
globals: {
...globals.browser,
DomElementInfo: "readonly",
SvgElementInfo: "readonly",
activeDocument: "readonly",
activeWindow: "readonly",
ajax: "readonly",
ajaxPromise: "readonly",
createDiv: "readonly",
createEl: "readonly",
createFragment: "readonly",
createSpan: "readonly",
createSvg: "readonly",
fish: "readonly",
fishAll: "readonly",
isBoolean: "readonly",
nextFrame: "readonly",
ready: "readonly",
sleep: "readonly"
}
},
// Custom rule overrides${Object.keys(customRules).length > 0 ? ' (migrated from .eslintrc)' : ''}
// Note: Console and async/await rules match Obsidian bot requirements
rules: {
${rulesString}
},
},
{
files: ["**/*.mjs"],
languageOptions: {
globals: {
console: "readonly",
process: "readonly",
Buffer: "readonly",
__dirname: "readonly",
__filename: "readonly"
}
}
},
]);
`;
}
// Note: pnpm handles peer dependencies differently than npm
// The legacy-peer-deps flag is npm-specific and not needed for pnpm
const PNPMRC_CONTENT = "# pnpm configuration\n";
function parseVersion(versionString) {
// Remove ^, ~, >=, etc. and extract major.minor.patch
const clean = versionString.replace(/^[\^~>=<]/, '');
const parts = clean.split('.').map(Number);
return {
major: parts[0] || 0,
minor: parts[1] || 0,
patch: parts[2] || 0
};
}
// Removed unused compareVersions function
function isVersionCompatible(currentVersion, minVersion) {
const current = parseVersion(currentVersion);
const min = parseVersion(minVersion);
if (current.major > min.major) return true;
if (current.major < min.major) return false;
if (current.minor > min.minor) return true;
if (current.minor < min.minor) return false;
return current.patch >= min.patch;
}
function migrateEslintrc(eslintrcPath) {
try {
const eslintrcContent = readFileSync(eslintrcPath, 'utf8');
const eslintrc = JSON.parse(eslintrcContent);
// Extract custom rules from .eslintrc
const customRules = eslintrc.rules || {};
console.log('✓ Found .eslintrc file - migrating rules to flat config format');
return customRules;
} catch (error) {
if (error instanceof SyntaxError) {
console.log('⚠ Warning: .eslintrc file is not valid JSON, using default rules');
} else {
console.log('⚠ Warning: Could not read .eslintrc file, using default rules');
}
return {};
}
}
function fixBuiltinModules(esbuildConfigPath, projectRoot) {
if (!existsSync(esbuildConfigPath)) {
return false;
}
try {
let content = readFileSync(esbuildConfigPath, 'utf8');
let updated = false;
// Check if it uses builtin-modules package
if (content.includes("import builtins from \"builtin-modules\"") ||
content.includes("import builtins from 'builtin-modules'")) {
// Replace the import
content = content.replace(
/import\s+builtins\s+from\s+["']builtin-modules["'];?/g,
"import { builtinModules } from \"module\";"
);
updated = true;
console.log('✓ Updated esbuild.config.mjs: replaced builtin-modules with module.builtinModules');
}
// Check if it uses ...builtins (the spread)
if (content.includes("...builtins") && !content.includes("...builtinModules")) {
content = content.replace(/\.\.\.builtins/g, "...builtinModules");
updated = true;
console.log('✓ Updated esbuild.config.mjs: replaced builtins with builtinModules');
}
// Add existsSync import if not present and update entryPoints to detect main.ts location
const hasExistsSyncImport = content.includes("existsSync") && (content.includes("from \"fs\"") || content.includes("from 'fs'"));
const needsExistsSync = !hasExistsSyncImport;
// Check if entryPoints needs to be updated to use detection logic
const hasHardcodedEntryPoint = /entryPoints:\s*\[["'](src\/)?main\.ts["']\]/.test(content);
const hasEntryPointVar = /const\s+entryPoint\s*=/.test(content);
const entryPointIndex = hasEntryPointVar ? content.indexOf('const entryPoint') : -1;
const esbuildContextIndex = content.indexOf('esbuild.context');
const hasCorrectEntryPoint = hasEntryPointVar &&
entryPointIndex >= 0 &&
esbuildContextIndex >= 0 &&
entryPointIndex < esbuildContextIndex;
const hasEntryPointDetection = hasCorrectEntryPoint || (hasEntryPointVar && content.includes("existsSync(\"src/main.ts\")") || content.includes("existsSync('src/main.ts')"));
// Check if the more sophisticated entry point detection pattern exists (with hasSrcMain, hasRootMain, warnings, etc.)
// This pattern includes detection of both src/main.ts and main.ts with proper warnings/errors
const hasAdvancedEntryPointDetection = /const\s+hasSrcMain\s*=/.test(content) &&
/const\s+hasRootMain\s*=/.test(content) &&
/const\s+entryPoint\s*=\s*hasSrcMain/.test(content);
// Check if there's a simple pattern that should be upgraded to advanced
const hasSimplePattern = /const\s+entryPoint\s*=\s*existsSync\(["']src\/main\.ts["']\)\s*\?/.test(content) &&
!hasAdvancedEntryPointDetection;
// Always remove ALL entryPoint declarations first (we'll add it back in the correct place)
// This ensures we don't have duplicates or incorrectly placed ones
// BUT: Skip removal if the advanced detection pattern exists (it's already correct)
// ALSO skip removal if we have a simple pattern (we'll upgrade it instead)
if (hasEntryPointVar && !hasAdvancedEntryPointDetection && !hasSimplePattern) {
// Remove comment lines that mention entry point detection
content = content.replace(/\s*\/\/\s*.*[Dd]etect\s+entry\s+point.*?\n/gi, '');
content = content.replace(/\s*\/\/\s*.*entry\s+point.*?\n/gi, '');
// Remove const entryPoint declarations - match the full line including any whitespace
content = content.replace(/^\s*const\s+entryPoint\s*=.*?;\s*$/gm, '');
// Also remove any that might be on the same line as other code (less common)
content = content.replace(/\s*const\s+entryPoint\s*=\s*existsSync\([^)]+\)\s*\?[^;]+;\s*/g, '');
content = content.replace(/\s*const\s+entryPoint\s*=\s*[^;]+;\s*/g, '');
// Clean up any double newlines that might result
content = content.replace(/\n\n\n+/g, '\n\n');
updated = true;
console.log('✓ Removed existing entryPoint declaration(s)');
}
// Skip updating if advanced detection pattern already exists
if (hasAdvancedEntryPointDetection) {
// Already has the full detection pattern with warnings/errors, nothing to do
if (hasHardcodedEntryPoint) {
// But still fix entryPoints line if it's hardcoded
content = content.replace(
/(\s+)entryPoints:\s*\[["'](src\/)?main\.ts["']\],?/,
"$1entryPoints: [entryPoint],"
);
updated = true;
console.log('✓ Updated esbuild.config.mjs: fixed entryPoints to use entryPoint variable');
}
} else if (hasSimplePattern) {
// Upgrade simple pattern to advanced pattern with warnings
// Find the simple pattern (with or without comment) and replace it with the advanced one
// Match: optional comment, then const entryPoint = existsSync("src/main.ts") ? "src/main.ts" : "main.ts";
const simplePatternRegex = /(\/\/\s*Detect\s+entry\s+point[^\n]*\n\s*)?const\s+entryPoint\s*=\s*existsSync\(["']src\/main\.ts["']\)\s*\?[^;]+;/;
if (simplePatternRegex.test(content)) {
// Find where to insert (before esbuild.context)
const esbuildContextMatch = content.match(/(\s*)(const\s+\w+\s*=\s*(?:await\s+)?esbuild\.context\s*\()/);
if (esbuildContextMatch) {
const indent = esbuildContextMatch[1];
const advancedPattern = `${indent}// Detect entry point: prefer src/main.ts, fallback to main.ts
${indent}const hasSrcMain = existsSync("src/main.ts");
${indent}const hasRootMain = existsSync("main.ts");
${indent}
${indent}if (hasSrcMain && hasRootMain) {
${indent} console.warn("WARNING: Both src/main.ts and main.ts exist. Using src/main.ts as entry point.");
${indent} console.warn("Consider removing one to avoid confusion.");
${indent}}
${indent}if (!hasSrcMain && !hasRootMain) {
${indent} console.error("ERROR: Neither src/main.ts nor main.ts found!");
${indent} process.exit(1);
${indent}}
${indent}
${indent}// Set entry point based on what exists
${indent}const entryPoint = hasSrcMain ? "src/main.ts" : "main.ts";
`;
// Remove the simple pattern
content = content.replace(simplePatternRegex, '');
// Insert advanced pattern before esbuild.context
content = content.replace(/(\s*)(const\s+\w+\s*=\s*(?:await\s+)?esbuild\.context\s*\()/, advancedPattern + '$1$2');
updated = true;
console.log('✓ Upgraded esbuild.config.mjs: enhanced entry point detection with warnings and error handling');
}
}
} else if (hasHardcodedEntryPoint || (hasEntryPointVar && !hasCorrectEntryPoint)) {
// Add existsSync import if needed
if (needsExistsSync) {
// Find the import statement and add existsSync to it, or add a new import
if (content.includes("import { builtinModules } from \"module\"")) {
content = content.replace(
/import\s+{\s*builtinModules\s*}\s+from\s+["']module["']/,
"import { builtinModules } from \"module\";\nimport { existsSync } from \"fs\";"
);
} else if (content.includes("import { builtinModules } from 'module'")) {
content = content.replace(
/import\s+{\s*builtinModules\s*}\s+from\s+['"]module['"]/,
"import { builtinModules } from 'module';\nimport { existsSync } from 'fs';"
);
} else {
// Add import after process import or at the top
const processImportMatch = content.match(/import\s+process\s+from\s+["']process["'];?/);
if (processImportMatch) {
content = content.replace(
/(import\s+process\s+from\s+["']process["'];?)/,
"$1\nimport { existsSync } from \"fs\";"
);
} else {
// Add at the beginning after first import
const firstImportMatch = content.match(/^import\s+[^;]+;?/m);
if (firstImportMatch) {
content = content.replace(
/^(import\s+[^;]+;?)/m,
"$1\nimport { existsSync } from \"fs\";"
);
}
}
}
updated = true;
console.log('✓ Updated esbuild.config.mjs: added existsSync import');
}
// Replace hardcoded entryPoints with detection logic or fix incorrectly placed ones
// Re-check after removal (content may have changed)
const entryPointPattern = /entryPoints:\s*\[["'](src\/)?main\.ts["']\]/;
const currentHasEntryPointVar = /const\s+entryPoint\s*=/.test(content);
const currentEntryPointIndex = currentHasEntryPointVar ? content.indexOf('const entryPoint') : -1;
const currentEsbuildContextIndex = content.indexOf('esbuild.context');
const currentHasCorrectEntryPoint = currentHasEntryPointVar &&
currentEntryPointIndex >= 0 &&
currentEsbuildContextIndex >= 0 &&
currentEntryPointIndex < currentEsbuildContextIndex;
// If we have a hardcoded entry point OR the entryPoint var is missing/incorrectly placed, fix it
if (entryPointPattern.test(content) || !currentHasCorrectEntryPoint) {
// Add entryPoint declaration if it doesn't exist or is incorrectly placed
if (!currentHasCorrectEntryPoint) {
// Find the "const prod" line - it's almost always present and right before esbuild.context
const prodMatch = content.match(/(const\s+prod\s*=.*?;)\s*\n/);
if (prodMatch) {
// Insert advanced entry point detection right after const prod line
const entryPointCode = `\n// Detect entry point: prefer src/main.ts, fallback to main.ts\nconst hasSrcMain = existsSync("src/main.ts");\nconst hasRootMain = existsSync("main.ts");\n\nif (hasSrcMain && hasRootMain) {\n console.warn("WARNING: Both src/main.ts and main.ts exist. Using src/main.ts as entry point.");\n console.warn("Consider removing one to avoid confusion.");\n}\nif (!hasSrcMain && !hasRootMain) {\n console.error("ERROR: Neither src/main.ts nor main.ts found!");\n process.exit(1);\n}\n\n// Set entry point based on what exists\nconst entryPoint = hasSrcMain ? "src/main.ts" : "main.ts";\n`;
content = content.replace(
/(const\s+prod\s*=.*?;)\s*\n/,
"$1" + entryPointCode
);
} else {
// Fallback: find esbuild.context and insert before it (but outside object literal)
// Look for the line that starts with "const context = await esbuild.context" or similar
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (/^\s*const\s+\w+\s*=\s*(?:await\s+)?esbuild\.context\s*\(/.test(lines[i])) {
const indent = lines[i].match(/^(\s*)/)[1];
const entryPointCode = `${indent}// Detect entry point: prefer src/main.ts, fallback to main.ts\n${indent}const hasSrcMain = existsSync("src/main.ts");\n${indent}const hasRootMain = existsSync("main.ts");\n${indent}\n${indent}if (hasSrcMain && hasRootMain) {\n${indent} console.warn("WARNING: Both src/main.ts and main.ts exist. Using src/main.ts as entry point.");\n${indent} console.warn("Consider removing one to avoid confusion.");\n${indent}}\n${indent}if (!hasSrcMain && !hasRootMain) {\n${indent} console.error("ERROR: Neither src/main.ts nor main.ts found!");\n${indent} process.exit(1);\n${indent}}\n${indent}\n${indent}// Set entry point based on what exists\n${indent}const entryPoint = hasSrcMain ? "src/main.ts" : "main.ts";\n`;
lines.splice(i, 0, entryPointCode);
content = lines.join('\n');
break;
}
}
}
}
// Then replace the entryPoints line to use the variable
content = content.replace(
/(\s+)entryPoints:\s*\[["'](src\/)?main\.ts["']\],?/,
"$1entryPoints: [entryPoint],"
);
updated = true;
console.log('✓ Updated esbuild.config.mjs: added entry point detection (supports both src/main.ts and main.ts)');
} else if (hasEntryPointVar && /entryPoints:\s*\[entryPoint\]/.test(content)) {
// Already has entry point detection, nothing to do
} else if (hasEntryPointVar) {
// Has entryPoint var but entryPoints line doesn't use it - fix that
content = content.replace(
/(\s+)entryPoints:\s*\[["'][^"']+["']\],?/,
"$1entryPoints: [entryPoint],"
);
updated = true;
console.log('✓ Updated esbuild.config.mjs: fixed entryPoints to use entryPoint variable');
}
}
// Ensure outfile is set to "main.js" (always output to root)
// Remove any production/development logic that outputs to dist/
const hasProdLogic = /const\s+prod\s*=.*process\.argv/.test(content);
const hasDistOutput = /outfile.*dist\/main\.js/.test(content);
// Remove production logic if it exists (simplify to always output to root)
if (hasProdLogic) {
// Remove const prod line
content = content.replace(/const\s+prod\s*=.*process\.argv[^;]+;\s*\n?/g, '');
// Remove dist/ directory creation logic
content = content.replace(/if\s*\(prod\s*&&\s*!existsSync\(["']dist["']\)\)\s*\{[^}]+\}\s*\n?/g, '');
// Remove production-specific messages
content = content.replace(/if\s*\(prod\)\s*\{[^}]+\}\s*else\s*\{[^}]+\}\s*\n?/g, '');
updated = true;
console.log('✓ Updated esbuild.config.mjs: removed production/development split (always outputs to root)');
}
// Ensure outfile is set to "main.js" (not dist/main.js)
if (hasDistOutput || /outfile:\s*outfile/.test(content)) {
// Replace any outfile variable or dist/main.js with simple "main.js"
content = content.replace(
/(\s+)outfile:\s*(outfile|["']dist\/main\.js["']|["']main\.js["']),?/,
'$1outfile: "main.js",'
);
// Remove outfile variable declaration if it exists
content = content.replace(/const\s+outfile\s*=.*?;\s*\n?/g, '');
updated = true;
console.log('✓ Updated esbuild.config.mjs: fixed outfile to always output to root');
} else if (!/outfile:/.test(content)) {
// Add outfile if it doesn't exist
const contextConfigMatch = content.match(/(esbuild\.context\s*\(\s*\{[^}]*?)(\n\s*\})/s);
if (contextConfigMatch) {
const indent = contextConfigMatch[1].match(/(\n\s+)$/)?.[1] || '\n\t';
content = content.replace(
/(esbuild\.context\s*\(\s*\{[^}]*?)(\n\s*\})/s,
`$1${indent}outfile: "main.js",$2`
);
updated = true;
console.log('✓ Updated esbuild.config.mjs: added outfile to esbuild.context');
}
}
// Fix build mode detection to handle both "build" and "production" arguments
// SIMPLE AND DIRECT: Check if production is missing, then fix it
const hasProductionInCheck = /isOneTimeBuild.*production/.test(content) ||
/args\.includes\(["']production["']\)/.test(content);
const hasIsOneTimeBuild = /const\s+isOneTimeBuild\s*=/.test(content);
const hasArgsSlice = /const\s+args\s*=\s*process\.argv\.slice\(2\)/.test(content);
// If we have isOneTimeBuild but no production check, FIX IT
if (hasIsOneTimeBuild && !hasProductionInCheck) {
// Ensure args.slice exists first
if (!hasArgsSlice) {
content = content.replace(
/(const\s+isOneTimeBuild\s*=)/,
'const args = process.argv.slice(2);\n$1'
);
updated = true;
}
// Now fix the actual check - use a simple line-by-line approach
const lines = content.split('\n');
let fixed = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// If this line has isOneTimeBuild with args.includes("build") but no production
if (/const\s+isOneTimeBuild\s*=/.test(line) && /args\.includes\(["']build["']\)/.test(line) && !/production/.test(line)) {
lines[i] = line.replace(
/(args\.includes\(["']build["']\))/,
'args.includes("build") || args.includes("production")'
);
fixed = true;
updated = true;
break;
}
// If this line has isOneTimeBuild with process.argv[2] === "build"
else if (/const\s+isOneTimeBuild\s*=/.test(line) && /process\.argv\[2\]/.test(line) && !/production/.test(line)) {
lines[i] = line.replace(
/(process\.argv\[2\]\s*===?\s*["']build["'])/,
'args.includes("build") || args.includes("production")'
);
fixed = true;
updated = true;
break;
}
// If this is the isOneTimeBuild line but the check is on the next line
else if (/const\s+isOneTimeBuild\s*=/.test(line) && i + 1 < lines.length) {
const nextLine = lines[i + 1];
if (/args\.includes\(["']build["']\)/.test(nextLine) && !/production/.test(nextLine)) {
lines[i + 1] = nextLine.replace(
/(args\.includes\(["']build["']\))/,
'args.includes("build") || args.includes("production")'
);
fixed = true;
updated = true;
break;
}
}
}
if (fixed) {
content = lines.join('\n');
console.log('✓ Updated esbuild.config.mjs: build mode detection now supports both "build" and "production" arguments');
}
} else if (!hasIsOneTimeBuild && !hasArgsSlice) {
// Add build mode detection if it doesn't exist
const watchModeMatch = content.match(/(await\s+context\.watch\(\))/);
if (watchModeMatch) {
const beforeWatch = content.substring(0, watchModeMatch.index);
const afterWatch = content.substring(watchModeMatch.index);
const buildModeCheck = `\n// Check if this is a one-time build or watch mode\n// Check for "build" or "production" argument - supports both patterns\nconst args = process.argv.slice(2);\nconst isOneTimeBuild = args.includes("build") || args.includes("production");\n\nif (isOneTimeBuild) {\n\t// Production build: build once and exit\n\tawait context.rebuild();\n\tconsole.log("\\n✓ Build complete!");\n\tconsole.log("📦 Release files:");\n\tconsole.log(" - main.js");\n\tif (existsSync("manifest.json")) {\n\t\tconsole.log(" - manifest.json");\n\t}\n\tif (existsSync("styles.css")) {\n\t\tconsole.log(" - styles.css");\n\t}\n\tconsole.log("\\n💡 Upload these files to GitHub releases\\n");\n\tawait context.dispose();\n\tprocess.exit(0);\n} else {\n\t// Development mode: watch for changes\n\tconsole.log("\\n✓ Development build running in watch mode");\n\tconsole.log("📝 Building to main.js in root");\n\tconsole.log("💡 For production builds, run: pnpm build\\n");\n\t`;
content = beforeWatch + buildModeCheck + afterWatch;
updated = true;
console.log('✓ Updated esbuild.config.mjs: added build mode detection (supports both "build" and "production")');
}
}
if (updated) {
writeFileSync(esbuildConfigPath, content, 'utf8');
return true;
}
return false;
} catch (error) {
console.log('⚠ Warning: Could not update esbuild.config.mjs:', error.message);
return false;
}
}
function setupESLint() {
// Check Node.js version (requires v16+)
const nodeVersion = process.version;
const majorVersion = parseInt(nodeVersion.slice(1).split('.')[0]);
if (majorVersion < 16) {
console.error(`❌ Error: Node.js v16+ is required (found ${nodeVersion})`);
console.error('Please upgrade Node.js from https://nodejs.org/');
process.exit(1);
}
// Get the directory where this script is located
const scriptDir = dirname(fileURLToPath(import.meta.url));
// Resolve project root (one level up from scripts folder)
const projectRoot = join(scriptDir, '..');
const packageJsonPath = join(projectRoot, 'package.json');
const eslintConfigPath = join(projectRoot, 'eslint.config.mjs');
const esbuildConfigPath = join(projectRoot, 'esbuild.config.mjs');
const eslintrcPath = join(projectRoot, '.eslintrc');
const eslintrcJsonPath = join(projectRoot, '.eslintrc.json');
// Note: pnpm doesn't need .npmrc with legacy-peer-deps
// pnpm handles peer dependencies more strictly by default
try {
// Read package.json
const packageJsonContent = readFileSync(packageJsonPath, 'utf8');
const packageJson = JSON.parse(packageJsonContent);
let updated = false;
let migratingFromEslint8 = false;
let customRules = {};
// Check if migrating from ESLint 8
const currentEslintVersion = packageJson.devDependencies?.eslint || packageJson.dependencies?.eslint;
if (currentEslintVersion && currentEslintVersion.match(/^[\^~]?8\./)) {
migratingFromEslint8 = true;
console.log('🔄 Detected ESLint 8 - migrating to ESLint 9...\n');
}
// Check for legacy .eslintrc files and migrate rules
if (existsSync(eslintrcPath)) {
customRules = migrateEslintrc(eslintrcPath);
} else if (existsSync(eslintrcJsonPath)) {
customRules = migrateEslintrc(eslintrcJsonPath);
}
// Check and update TypeScript version
if (!packageJson.devDependencies) {
packageJson.devDependencies = {};
updated = true;
}
const currentTsVersion = packageJson.devDependencies.typescript || packageJson.dependencies?.typescript;
if (currentTsVersion && !isVersionCompatible(currentTsVersion, MIN_TYPESCRIPT_VERSION)) {
console.log(`⚠ TypeScript version ${currentTsVersion} is not compatible with ESLint (requires >=4.8.4)`);
console.log(`✓ Updating TypeScript to ${MIN_TYPESCRIPT_VERSION}`);
packageJson.devDependencies.typescript = MIN_TYPESCRIPT_VERSION;
updated = true;
} else if (!currentTsVersion) {
console.log(`✓ Adding TypeScript ${MIN_TYPESCRIPT_VERSION}`);
packageJson.devDependencies.typescript = MIN_TYPESCRIPT_VERSION;
updated = true;
}
// Remove deprecated builtin-modules package if it exists
if (packageJson.devDependencies?.["builtin-modules"]) {
delete packageJson.devDependencies["builtin-modules"];
updated = true;
console.log('✓ Removed deprecated builtin-modules package (use module.builtinModules instead)');
}
if (packageJson.dependencies?.["builtin-modules"]) {
delete packageJson.dependencies["builtin-modules"];
updated = true;
console.log('✓ Removed deprecated builtin-modules package (use module.builtinModules instead)');
}
// Add or update ESLint devDependencies
for (const [dep, version] of Object.entries(ESLINT_DEPS)) {
if (!packageJson.devDependencies[dep] || packageJson.devDependencies[dep] !== version) {
packageJson.devDependencies[dep] = version;
updated = true;
console.log(`✓ Added/updated devDependency: ${dep}@${version}`);
}
}
// Add or update scripts
if (!packageJson.scripts) {
packageJson.scripts = {};
updated = true;
}
for (const [script, command] of Object.entries(ESLINT_SCRIPTS)) {
const currentCommand = packageJson.scripts[script];
// Remove --ext flag if present (ESLint 8 legacy)
if (currentCommand && currentCommand.includes('--ext')) {
console.log(`✓ Updating ${script} script: removing --ext flag (not needed in ESLint 9)`);
updated = true;
}
if (!currentCommand || currentCommand !== command) {
packageJson.scripts[script] = command;
updated = true;
if (currentCommand) {
console.log(`✓ Updated script: ${script}`);
} else {
console.log(`✓ Added script: ${script}`);
}
}
}
// Generate eslint.config.mjs file (flat config for ESLint 9)
let eslintConfigUpdated = false;
const newConfig = generateEslintConfig(customRules);
if (!existsSync(eslintConfigPath)) {
writeFileSync(eslintConfigPath, newConfig, 'utf8');
console.log('✓ Created eslint.config.mjs configuration file');
eslintConfigUpdated = true;
} else {
// Update existing file to ensure it has the correct config
const existingContent = readFileSync(eslintConfigPath, 'utf8');
if (existingContent.trim() !== newConfig.trim()) {
writeFileSync(eslintConfigPath, newConfig, 'utf8');
console.log('✓ Updated eslint.config.mjs configuration file');
eslintConfigUpdated = true;
}
}
// Remove legacy .eslintrc files after migration
if (existsSync(eslintrcPath)) {
try {
unlinkSync(eslintrcPath);
console.log('✓ Removed legacy .eslintrc file (migrated to eslint.config.mjs)');
} catch {
console.log('⚠ Warning: Could not remove .eslintrc file');
}
}
if (existsSync(eslintrcJsonPath)) {
try {
unlinkSync(eslintrcJsonPath);
console.log('✓ Removed legacy .eslintrc.json file (migrated to eslint.config.mjs)');
} catch {
console.log('⚠ Warning: Could not remove .eslintrc.json file');
}
}
// Fix builtin-modules in esbuild.config.mjs and entryPoints
const esbuildConfigUpdated = fixBuiltinModules(esbuildConfigPath, projectRoot);
// Generate and copy lint-wrapper.mjs if it doesn't exist or needs updating
const lintWrapperPath = join(projectRoot, 'scripts', 'lint-wrapper.mjs');
const lintWrapperSource = generateLintWrapper();
let lintWrapperUpdated = false;
// Ensure scripts directory exists
const scriptsDir = join(projectRoot, 'scripts');
if (!existsSync(scriptsDir)) {
mkdirSync(scriptsDir, { recursive: true });
}
if (!existsSync(lintWrapperPath)) {
writeFileSync(lintWrapperPath, lintWrapperSource, 'utf8');
console.log('✓ Created scripts/lint-wrapper.mjs');
lintWrapperUpdated = true;
} else {
// Update if content differs (in case of updates)
const existingContent = readFileSync(lintWrapperPath, 'utf8');
if (existingContent !== lintWrapperSource) {
writeFileSync(lintWrapperPath, lintWrapperSource, 'utf8');
console.log('✓ Updated scripts/lint-wrapper.mjs');
lintWrapperUpdated = true;
}
}
// Note: pnpm doesn't require .npmrc with legacy-peer-deps
// pnpm handles peer dependencies more strictly by default, which is better
// If users need pnpm-specific config, they can create .pnpmrc manually
let npmrcUpdated = false;
if (updated) {
// Write back to package.json with proper formatting
const updatedContent = JSON.stringify(packageJson, null, '\t') + '\n';
writeFileSync(packageJsonPath, updatedContent, 'utf8');
console.log('\n✓ package.json updated successfully!');
}
if (updated || eslintConfigUpdated || esbuildConfigUpdated || npmrcUpdated || lintWrapperUpdated) {
console.log('\n✓ ESLint setup complete!');
if (migratingFromEslint8) {
console.log('✓ Successfully migrated from ESLint 8 to ESLint 9');
}
console.log('\nNext steps:');
console.log(' 1. Run: pnpm install');
console.log(' 2. Run: pnpm lint');
} else {
console.log('✓ Everything is already set up correctly!');
console.log(' Run: pnpm lint');
}
} catch (error) {
if (error.code === 'ENOENT') {
console.error('❌ Error: package.json not found in project root');
process.exit(1);
} else if (error instanceof SyntaxError) {
console.error('❌ Error: package.json is not valid JSON');
console.error(error.message);
process.exit(1);
} else {
console.error('❌ Error:', error.message);
process.exit(1);
}
}
}
setupESLint();