Skip to content

Commit ba88a4e

Browse files
authored
feat: add migrate command (#20)
* feat: add legacy eslint migration command * fix: resolve parent eslint config in monorepos * refactor: inline eslint ignores into generated flat config
1 parent d84af42 commit ba88a4e

7 files changed

Lines changed: 290 additions & 76 deletions

File tree

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ npx mwts init
2626
Use `--formatter stylistic` to opt into `ESLint + Stylistic` instead of the default `Prettier + ESLint`.
2727
Use `--formatter biome` to use Biome as formatter while keeping mwts lint/check commands.
2828

29+
For legacy mwts 1.x projects, run:
30+
31+
```sh
32+
npx mwts migrate
33+
```
34+
2935
## How it works
3036
When you run the `npx mwts init` command, it's going to do a few things for you:
3137
- Adds an opinionated `tsconfig.json` file to your project that uses the MidwayJS TypeScript Style.
@@ -111,11 +117,12 @@ Show your love for `mwts` and include a badge!
111117
- Upgrade runtime/tooling baseline:
112118
- Node.js >= 20
113119
- TypeScript >= 5
114-
- `mwts init` now generates `eslint.config.js` and `eslint.ignores.js` (flat config), instead of `.eslintrc.json` and `.eslintignore`.
120+
- `mwts init` now generates `eslint.config.js` (flat config), instead of `.eslintrc.json` / `.eslintignore`.
115121
- Default formatter mode stays `Prettier + ESLint`.
116122
- Optional formatter mode: `mwts init --formatter stylistic`.
117123
- Optional formatter mode: `mwts init --formatter biome`.
118124
- `mwts lint/check/fix` support per-file arguments and can run without `mwts init` by falling back to mwts built-in config.
125+
- You can run `mwts migrate` once to generate a flat `eslint.config.js` from legacy `.eslintrc.json`.
119126

120127
## License
121128
[Apache-2.0](LICENSE)

src/cli.ts

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as path from 'path';
44
import * as meow from 'meow';
55
import { init } from './init';
66
import { clean } from './clean';
7+
import { migrate } from './migrate';
78
import { isYarnUsed, readJSON } from './util';
89
import * as execa from 'execa';
910
import { PackageJSON as PackageJson } from '@npm/types';
@@ -51,6 +52,7 @@ const cli = meow({
5152
check Alias for lint. Kept for backward compatibility.
5253
fix Fixes formatting and linting issues (if possible).
5354
clean Removes all files generated by the build.
55+
migrate Migrates legacy mwts/eslintrc setup to flat config.
5456
5557
Options
5658
--help Prints this help message.
@@ -65,6 +67,7 @@ const cli = meow({
6567
$ mwts lint
6668
$ mwts fix
6769
$ mwts fix src/file1.ts src/file2.ts
70+
$ mwts migrate
6871
$ mwts clean`,
6972
flags: {
7073
help: { type: 'boolean' },
@@ -124,12 +127,27 @@ function usage(msg?: string): void {
124127
cli.showHelp(1);
125128
}
126129

127-
function hasLocalEslintConfig(targetRootDir: string): boolean {
128-
return (
129-
fs.existsSync(path.join(targetRootDir, 'eslint.config.js')) ||
130-
fs.existsSync(path.join(targetRootDir, 'eslint.config.cjs')) ||
131-
fs.existsSync(path.join(targetRootDir, 'eslint.config.mjs'))
132-
);
130+
function findNearestEslintConfig(targetRootDir: string): string | undefined {
131+
const configNames = [
132+
'eslint.config.js',
133+
'eslint.config.cjs',
134+
'eslint.config.mjs',
135+
];
136+
let currentDir = path.resolve(targetRootDir);
137+
while (true) {
138+
for (const configName of configNames) {
139+
const configPath = path.join(currentDir, configName);
140+
if (fs.existsSync(configPath)) {
141+
return configPath;
142+
}
143+
}
144+
145+
const parentDir = path.dirname(currentDir);
146+
if (parentDir === currentDir) {
147+
return undefined;
148+
}
149+
currentDir = parentDir;
150+
}
133151
}
134152

135153
export async function run(verb: string, files: string[]): Promise<boolean> {
@@ -179,11 +197,11 @@ export async function run(verb: string, files: string[]): Promise<boolean> {
179197
case 'lint':
180198
case 'check': {
181199
const eslintFlags = [...flags];
182-
if (!hasLocalEslintConfig(options.targetRootDir)) {
183-
eslintFlags.unshift(
184-
'--config',
185-
path.join(options.mwtsRootDir, 'eslint.config.js')
186-
);
200+
const resolvedEslintConfig =
201+
findNearestEslintConfig(options.targetRootDir) ||
202+
path.join(options.mwtsRootDir, 'eslint.config.js');
203+
if (resolvedEslintConfig) {
204+
eslintFlags.unshift('--config', resolvedEslintConfig);
187205
}
188206
try {
189207
await execa('eslint', eslintFlags, {
@@ -210,11 +228,11 @@ export async function run(verb: string, files: string[]): Promise<boolean> {
210228
}
211229
const fixFlag = options.dryRun ? '--fix-dry-run' : '--fix';
212230
const eslintFlags = [fixFlag, ...flags];
213-
if (!hasLocalEslintConfig(options.targetRootDir)) {
214-
eslintFlags.unshift(
215-
'--config',
216-
path.join(options.mwtsRootDir, 'eslint.config.js')
217-
);
231+
const resolvedEslintConfig =
232+
findNearestEslintConfig(options.targetRootDir) ||
233+
path.join(options.mwtsRootDir, 'eslint.config.js');
234+
if (resolvedEslintConfig) {
235+
eslintFlags.unshift('--config', resolvedEslintConfig);
218236
}
219237
try {
220238
await execa('eslint', eslintFlags, {
@@ -228,6 +246,8 @@ export async function run(verb: string, files: string[]): Promise<boolean> {
228246
}
229247
case 'clean':
230248
return clean(options);
249+
case 'migrate':
250+
return migrate(options);
231251
default:
232252
usage(`Unknown verb: ${verb}`);
233253
return false;

src/init.ts

Lines changed: 13 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -35,41 +35,20 @@ const DEFAULT_PACKAGE_JSON: PackageJson = {
3535
scripts: { test: 'echo "Error: no test specified" && exit 1' },
3636
};
3737

38-
const ESLINT_CONFIG = `let customConfig = [];
39-
let hasIgnoresFile = false;
40-
try {
41-
require.resolve('./eslint.ignores.js');
42-
hasIgnoresFile = true;
43-
} catch {
44-
// eslint.ignores.js doesn't exist
45-
}
46-
47-
if (hasIgnoresFile) {
48-
const ignores = require('./eslint.ignores.js');
49-
customConfig = [{ ignores }];
50-
}
51-
52-
module.exports = [...customConfig, ...require('mwts')];
38+
const ESLINT_CONFIG = `module.exports = [
39+
{
40+
ignores: ['dist/', '**/node_modules/'],
41+
},
42+
...require('mwts'),
43+
];
5344
`;
5445

5546
const ESLINT_STYLISTIC_CONFIG = `const stylistic = require('@stylistic/eslint-plugin');
5647
57-
let customConfig = [];
58-
let hasIgnoresFile = false;
59-
try {
60-
require.resolve('./eslint.ignores.js');
61-
hasIgnoresFile = true;
62-
} catch {
63-
// eslint.ignores.js doesn't exist
64-
}
65-
66-
if (hasIgnoresFile) {
67-
const ignores = require('./eslint.ignores.js');
68-
customConfig = [{ ignores }];
69-
}
70-
7148
module.exports = [
72-
...customConfig,
49+
{
50+
ignores: ['dist/', '**/node_modules/'],
51+
},
7352
...require('mwts'),
7453
{
7554
plugins: {
@@ -87,22 +66,11 @@ module.exports = [
8766
];
8867
`;
8968

90-
const ESLINT_BIOME_CONFIG = `let customConfig = [];
91-
let hasIgnoresFile = false;
92-
try {
93-
require.resolve('./eslint.ignores.js');
94-
hasIgnoresFile = true;
95-
} catch {
96-
// eslint.ignores.js doesn't exist
97-
}
98-
99-
if (hasIgnoresFile) {
100-
const ignores = require('./eslint.ignores.js');
101-
customConfig = [{ ignores }];
102-
}
103-
69+
const ESLINT_BIOME_CONFIG = `
10470
module.exports = [
105-
...customConfig,
71+
{
72+
ignores: ['dist/', '**/node_modules/'],
73+
},
10674
...require('mwts'),
10775
{
10876
rules: {
@@ -131,8 +99,6 @@ const BIOME_CONFIG = `{
13199
}
132100
`;
133101

134-
export const ESLINT_IGNORE = "module.exports = ['dist/', '**/node_modules/']\n";
135-
136102
function getFormatterMode(options: Options): FormatterMode {
137103
return options.formatterMode || 'prettier';
138104
}
@@ -352,10 +318,6 @@ async function generateESLintConfig(options: Options): Promise<void> {
352318
return generateConfigFile(options, './eslint.config.js', config);
353319
}
354320

355-
async function generateESLintIgnore(options: Options): Promise<void> {
356-
return generateConfigFile(options, './eslint.ignores.js', ESLINT_IGNORE);
357-
}
358-
359321
async function generateTsConfig(options: Options): Promise<void> {
360322
const config = formatJson({
361323
extends: './node_modules/mwts/tsconfig-midway.json',
@@ -459,7 +421,6 @@ export async function init(options: Options): Promise<boolean> {
459421
await Promise.all([
460422
generateTsConfig(options),
461423
generateESLintConfig(options),
462-
generateESLintIgnore(options),
463424
generatePrettierConfig(options),
464425
generateBiomeConfig(options),
465426
]);

src/migrate.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
4+
import { Options } from './cli';
5+
import { safeError } from './util';
6+
7+
const DEFAULT_ESLINT_IGNORES = ['dist/', '**/node_modules/'];
8+
9+
function parseIgnorePatterns(contents: string): string[] {
10+
return contents
11+
.split(/\r?\n/u)
12+
.map(line => line.trim())
13+
.filter(line => line && !line.startsWith('#'));
14+
}
15+
16+
function formatIgnoreConfig(patterns: string[]): string {
17+
const escaped = patterns.map(pattern => `'${pattern.replace(/'/gu, "\\'")}'`);
18+
return `module.exports = [${escaped.join(', ')}]\n`;
19+
}
20+
21+
function buildMigratedEslintConfig(ignorePatterns: string[]): string {
22+
const ignoreList = formatIgnoreConfig(ignorePatterns).replace(
23+
/^module\.exports = /u,
24+
''
25+
);
26+
return `const mwtsConfig = require('mwts/eslint.config.js');
27+
28+
const compatConfig = mwtsConfig.map(config => {
29+
const parserOptions = config?.languageOptions?.parserOptions;
30+
if (!parserOptions) {
31+
return config;
32+
}
33+
const { project, projectService, ...nextParserOptions } = parserOptions;
34+
35+
return {
36+
...config,
37+
languageOptions: {
38+
...config.languageOptions,
39+
parserOptions: nextParserOptions,
40+
},
41+
};
42+
});
43+
44+
module.exports = [
45+
{
46+
ignores: ${ignoreList.trim()},
47+
},
48+
...compatConfig,
49+
{
50+
rules: {
51+
'@typescript-eslint/no-floating-promises': 'off',
52+
},
53+
},
54+
];
55+
`;
56+
}
57+
58+
export async function migrate(options: Options): Promise<boolean> {
59+
const root = options.targetRootDir;
60+
const eslintConfigCandidates = [
61+
'eslint.config.js',
62+
'eslint.config.cjs',
63+
'eslint.config.mjs',
64+
];
65+
const hasFlatConfig = eslintConfigCandidates.some(candidate =>
66+
fs.existsSync(path.join(root, candidate))
67+
);
68+
69+
if (hasFlatConfig) {
70+
options.logger.log('Detected existing eslint.config.*; skip migration.');
71+
return true;
72+
}
73+
74+
const legacyEslintConfig = path.join(root, '.eslintrc.json');
75+
const legacyEslintIgnore = path.join(root, '.eslintignore');
76+
const targetEslintConfig = path.join(root, 'eslint.config.js');
77+
78+
if (!fs.existsSync(legacyEslintConfig)) {
79+
options.logger.log(
80+
'No .eslintrc.json found. Nothing to migrate in current directory.'
81+
);
82+
return true;
83+
}
84+
85+
try {
86+
let ignorePatterns = DEFAULT_ESLINT_IGNORES;
87+
if (fs.existsSync(legacyEslintIgnore)) {
88+
const parsedIgnorePatterns = parseIgnorePatterns(
89+
fs.readFileSync(legacyEslintIgnore, 'utf8')
90+
);
91+
if (parsedIgnorePatterns.length > 0) {
92+
ignorePatterns = parsedIgnorePatterns;
93+
}
94+
}
95+
96+
options.logger.log('Writing eslint.config.js...');
97+
if (!options.dryRun) {
98+
fs.writeFileSync(
99+
targetEslintConfig,
100+
buildMigratedEslintConfig(ignorePatterns),
101+
'utf8'
102+
);
103+
}
104+
105+
options.logger.log('Backing up legacy ESLint files...');
106+
if (!options.dryRun) {
107+
fs.renameSync(legacyEslintConfig, `${legacyEslintConfig}.bak`);
108+
if (fs.existsSync(legacyEslintIgnore)) {
109+
fs.renameSync(legacyEslintIgnore, `${legacyEslintIgnore}.bak`);
110+
}
111+
}
112+
return true;
113+
} catch (exc) {
114+
const err = safeError(exc);
115+
options.logger.error(`Migration failed: ${err.message}`);
116+
return false;
117+
}
118+
}

0 commit comments

Comments
 (0)