lfify 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,7 +22,7 @@ jobs:
22
22
  - name: Setup Node.js
23
23
  uses: actions/setup-node@v6
24
24
  with:
25
- node-version: "lts/*"
25
+ node-version: "24"
26
26
  - name: Install dependencies
27
27
  run: npm clean-install
28
28
  - name: Verify the integrity of provenance attestations and registry signatures for installed dependencies
@@ -30,4 +30,6 @@ jobs:
30
30
  - name: Release
31
31
  env:
32
32
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33
- run: NODE_AUTH_TOKEN="" npx semantic-release
33
+ run: |
34
+ unset NODE_AUTH_TOKEN
35
+ npx semantic-release
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "entry": "./",
3
+ "logLevel": "error",
3
4
  "include": [
4
5
  "**/*.{js,ts,jsx,tsx}",
5
6
  "**/*.{json,md}",
package/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # [1.2.0](https://github.com/GyeongHoKim/lfify/compare/v1.1.0...v1.2.0) (2026-03-16)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * add provenance field to true ([fa1a7ff](https://github.com/GyeongHoKim/lfify/commit/fa1a7ffe9c101ff24ec72dd2a083a0facf414511))
7
+ * add trailing slash to publishConfig.registry for npm OIDC ([85175cd](https://github.com/GyeongHoKim/lfify/commit/85175cd160e4bce4d12af559a23e925baf6d7bd6))
8
+ * chunk process ([a77316f](https://github.com/GyeongHoKim/lfify/commit/a77316f792f26466f083c0f47092c79f135bd55a))
9
+ * declare tar resolutions ([e3d654d](https://github.com/GyeongHoKim/lfify/commit/e3d654df7dc67c4edade2043ee62e3e7d64e35a0)), closes [semantic-release/#2951](https://github.com/GyeongHoKim/lfify/issues/2951)
10
+ * dependencies ([87c9031](https://github.com/GyeongHoKim/lfify/commit/87c903143fde7c8c82d94a94d338e5467968ed4a))
11
+ * node 24 and unset NODE_AUTH_TOKEN for npm OIDC ([a74d183](https://github.com/GyeongHoKim/lfify/commit/a74d183248f25b187ef418127d80917c86dd6d86))
12
+
13
+
14
+ ### Features
15
+
16
+ * include, exclude arguments ([9f4f2dc](https://github.com/GyeongHoKim/lfify/commit/9f4f2dc962f28c1ada58d6da556a35ef9dc4411b))
17
+ * log level ([f44f9c4](https://github.com/GyeongHoKim/lfify/commit/f44f9c4f3cd846088728dffe67f33e0526e61ffb))
18
+
19
+
20
+ ### Reverts
21
+
22
+ * revert "ci: remove node_auth_token force reset" ([3d76f97](https://github.com/GyeongHoKim/lfify/commit/3d76f97ad85d9874b8998c2ac9940474eb95bb24))
@@ -0,0 +1 @@
1
+ const x = 1;
@@ -0,0 +1,6 @@
1
+ {
2
+ "entry": ".",
3
+ "logLevel": "error",
4
+ "include": ["**/*.txt"],
5
+ "exclude": ["**/none"]
6
+ }
@@ -0,0 +1 @@
1
+ const a = 1;
@@ -0,0 +1 @@
1
+ b content
@@ -0,0 +1,2 @@
1
+ [core]
2
+ repositoryformatversion = 0
@@ -0,0 +1 @@
1
+ module.exports = {};
@@ -0,0 +1 @@
1
+ console.log("app");
@@ -0,0 +1,2 @@
1
+ hello
2
+ world
@@ -0,0 +1,6 @@
1
+ {
2
+ "entry": ".",
3
+ "logLevel": "error",
4
+ "include": ["**/*.js"],
5
+ "exclude": ["skip/**"]
6
+ }
@@ -0,0 +1 @@
1
+ doc content
@@ -0,0 +1 @@
1
+ const x = 1;
@@ -0,0 +1 @@
1
+ skipped;
package/eslint.config.mjs CHANGED
@@ -4,7 +4,8 @@ import pluginJs from "@eslint/js";
4
4
 
5
5
  /** @type {import('eslint').Linter.Config[]} */
6
6
  export default [
7
- {files: ["**/*.js"], languageOptions: {sourceType: "commonjs"}},
7
+ { ignores: ["**/__fixtures__/**"] },
8
+ { files: ["**/*.js"], languageOptions: { sourceType: "commonjs" } },
8
9
  {
9
10
  languageOptions: {
10
11
  globals: {
@@ -14,4 +15,4 @@ export default [
14
15
  },
15
16
  },
16
17
  pluginJs.configs.recommended,
17
- ];
18
+ ];
package/index.cjs CHANGED
@@ -1,18 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const fs = require("fs").promises;
4
- const path = require("path");
5
- const micromatch = require("micromatch");
3
+ const { readFile, readdir, rename } = require("fs/promises");
4
+ const { createReadStream, createWriteStream } = require("fs");
5
+ const { resolve, join, relative } = require("path");
6
+ const { isMatch } = require("micromatch");
7
+ const { Transform } = require("stream");
8
+ const { pipeline } = require("stream/promises");
9
+
10
+ /** @type {ReadonlyArray<string>} */
11
+ const LOG_LEVELS = ['error', 'warn', 'info'];
6
12
 
7
13
  /**
8
14
  * @typedef {Object} Config
9
15
  * @property {string} entry - 처리할 시작 디렉토리 경로
10
16
  * @property {string[]} include - 포함할 파일 패턴 목록
11
17
  * @property {string[]} exclude - 제외할 파일 패턴 목록
18
+ * @property {'error'|'warn'|'info'} [logLevel] - 로그 레벨 (error: 에러만, warn: 에러+경고, info: 전체)
12
19
  */
13
20
 
14
21
  /**
15
22
  * @typedef {Object} Logger
23
+ * @property {function(string): void} setLogLevel - 로그 레벨 설정 ('error'|'warn'|'info')
16
24
  * @property {function(string, string): void} warn - 경고 메시지 출력
17
25
  * @property {function(string, string, Error=): void} error - 에러 메시지 출력
18
26
  * @property {function(string, string): void} info - 정보 메시지 출력
@@ -24,6 +32,7 @@ const micromatch = require("micromatch");
24
32
  * @property {string} [entry] - CLI로 지정한 entry 경로
25
33
  * @property {string[]} [include] - CLI로 지정한 include 패턴
26
34
  * @property {string[]} [exclude] - CLI로 지정한 exclude 패턴
35
+ * @property {'error'|'warn'|'info'} [logLevel] - CLI로 지정한 로그 레벨
27
36
  */
28
37
 
29
38
  /**
@@ -33,7 +42,8 @@ const micromatch = require("micromatch");
33
42
  const DEFAULT_CONFIG = {
34
43
  entry: './',
35
44
  include: [],
36
- exclude: []
45
+ exclude: [],
46
+ logLevel: 'error'
37
47
  };
38
48
 
39
49
  /**
@@ -49,7 +59,8 @@ const SENSIBLE_DEFAULTS = {
49
59
  'dist/**',
50
60
  'build/**',
51
61
  'coverage/**'
52
- ]
62
+ ],
63
+ logLevel: 'error'
53
64
  };
54
65
 
55
66
  /**
@@ -60,16 +71,41 @@ const CONFIG_SCHEMA = {
60
71
  entry: (value) => typeof value === 'string',
61
72
  include: (value) => Array.isArray(value) && value.length > 0 && value.every(item => typeof item === 'string'),
62
73
  exclude: (value) => Array.isArray(value) && value.length > 0 && value.every(item => typeof item === 'string'),
74
+ logLevel: (value) => LOG_LEVELS.includes(value),
63
75
  };
64
76
 
65
77
  /**
66
- * Logging utility
67
- * @type {Logger}
78
+ * Logging utility. 기본은 error만 출력. setLogLevel로 변경 가능.
79
+ * @type {Logger & { _level: string, setLogLevel: function(string): void }}
68
80
  */
69
81
  const logger = {
70
- warn: (msg, path) => console.warn(`${msg} ${path}`),
71
- error: (msg, path, err) => console.error(`${msg} ${path}`, err),
72
- info: (msg, path) => console.log(`${msg} ${path}`),
82
+ _level: 'error',
83
+
84
+ setLogLevel(level) {
85
+ if (LOG_LEVELS.includes(level)) {
86
+ this._level = level;
87
+ }
88
+ },
89
+
90
+ error(msg, path, err) {
91
+ if (err !== undefined) {
92
+ console.error(`${msg} ${path}`, err);
93
+ } else {
94
+ console.error(`${msg} ${path}`);
95
+ }
96
+ },
97
+
98
+ warn(msg, path) {
99
+ if (LOG_LEVELS.indexOf(this._level) >= LOG_LEVELS.indexOf('warn')) {
100
+ console.warn(`${msg} ${path}`);
101
+ }
102
+ },
103
+
104
+ info(msg, path) {
105
+ if (LOG_LEVELS.indexOf(this._level) >= LOG_LEVELS.indexOf('info')) {
106
+ console.log(`${msg} ${path}`);
107
+ }
108
+ },
73
109
  };
74
110
 
75
111
  /**
@@ -80,7 +116,7 @@ const logger = {
80
116
  */
81
117
  async function readConfig(configPath) {
82
118
  try {
83
- const configContent = await fs.readFile(configPath, 'utf8');
119
+ const configContent = await readFile(configPath, 'utf8');
84
120
  const config = JSON.parse(configContent);
85
121
 
86
122
  // Validate required fields
@@ -93,7 +129,7 @@ async function readConfig(configPath) {
93
129
  return {
94
130
  ...DEFAULT_CONFIG,
95
131
  ...config,
96
- entry: path.resolve(process.cwd(), config.entry || DEFAULT_CONFIG.entry)
132
+ entry: resolve(process.cwd(), config.entry || DEFAULT_CONFIG.entry)
97
133
  };
98
134
  } catch (err) {
99
135
  if (err.code === 'ENOENT') {
@@ -101,7 +137,7 @@ async function readConfig(configPath) {
101
137
  } else {
102
138
  logger.error(`Error reading configuration file: ${err.message}`, configPath);
103
139
  }
104
-
140
+
105
141
  if (require.main === module) {
106
142
  process.exit(1);
107
143
  }
@@ -120,7 +156,7 @@ async function resolveConfig(cliOptions) {
120
156
  // Try to load config file if it exists
121
157
  if (cliOptions.configPath) {
122
158
  try {
123
- const configContent = await fs.readFile(cliOptions.configPath, 'utf8');
159
+ const configContent = await readFile(cliOptions.configPath, 'utf8');
124
160
  fileConfig = JSON.parse(configContent);
125
161
 
126
162
  // Validate config file fields
@@ -143,14 +179,16 @@ async function resolveConfig(cliOptions) {
143
179
  const hasCLIInclude = Array.isArray(cliOptions.include) && cliOptions.include.length > 0;
144
180
  const hasCLIExclude = Array.isArray(cliOptions.exclude) && cliOptions.exclude.length > 0;
145
181
  const hasCLIEntry = typeof cliOptions.entry === 'string';
182
+ const hasCLILogLevel = typeof cliOptions.logLevel === 'string' && LOG_LEVELS.includes(cliOptions.logLevel);
146
183
 
147
184
  const hasFileConfig = fileConfig !== null;
148
185
  const hasFileInclude = hasFileConfig && Array.isArray(fileConfig.include) && fileConfig.include.length > 0;
149
186
  const hasFileExclude = hasFileConfig && Array.isArray(fileConfig.exclude) && fileConfig.exclude.length > 0;
150
187
  const hasFileEntry = hasFileConfig && typeof fileConfig.entry === 'string';
188
+ const hasFileLogLevel = hasFileConfig && fileConfig.logLevel && LOG_LEVELS.includes(fileConfig.logLevel);
151
189
 
152
190
  // Resolve each config property
153
- let include, exclude, entry;
191
+ let include, exclude, entry, logLevel;
154
192
 
155
193
  // Include: CLI > file > default
156
194
  if (hasCLIInclude) {
@@ -179,10 +217,20 @@ async function resolveConfig(cliOptions) {
179
217
  entry = SENSIBLE_DEFAULTS.entry;
180
218
  }
181
219
 
220
+ // LogLevel: CLI > file > default
221
+ if (hasCLILogLevel) {
222
+ logLevel = cliOptions.logLevel;
223
+ } else if (hasFileLogLevel) {
224
+ logLevel = fileConfig.logLevel;
225
+ } else {
226
+ logLevel = SENSIBLE_DEFAULTS.logLevel;
227
+ }
228
+
182
229
  return {
183
- entry: path.resolve(process.cwd(), entry),
230
+ entry: resolve(process.cwd(), entry),
184
231
  include,
185
- exclude
232
+ exclude,
233
+ logLevel
186
234
  };
187
235
  }
188
236
 
@@ -230,6 +278,13 @@ function parseArgs() {
230
278
  i++;
231
279
  }
232
280
  break;
281
+
282
+ case '--log-level':
283
+ if (nextArg && LOG_LEVELS.includes(nextArg)) {
284
+ options.logLevel = nextArg;
285
+ i++;
286
+ }
287
+ break;
233
288
  }
234
289
  }
235
290
 
@@ -243,8 +298,8 @@ function parseArgs() {
243
298
  * @returns {boolean} - true if file should be processed
244
299
  */
245
300
  function shouldProcessFile(filePath, config) {
246
- const isIncluded = micromatch.isMatch(filePath, config.include);
247
- const isExcluded = micromatch.isMatch(filePath, config.exclude);
301
+ const isIncluded = isMatch(filePath, config.include);
302
+ const isExcluded = isMatch(filePath, config.exclude);
248
303
 
249
304
  return isIncluded && !isExcluded;
250
305
  }
@@ -258,14 +313,14 @@ function shouldProcessFile(filePath, config) {
258
313
  */
259
314
  async function convertCRLFtoLF(dirPath, config) {
260
315
  try {
261
- const entries = await fs.readdir(dirPath, { withFileTypes: true });
316
+ const entries = await readdir(dirPath, { withFileTypes: true });
262
317
 
263
318
  /**
264
319
  * @todo Node.js is single-threaded, if I want to convert files in parallel, I need to use worker_threads
265
320
  */
266
321
  await Promise.all(entries.map(async entry => {
267
- const fullPath = path.join(dirPath, entry.name);
268
- const relativePath = path.relative(process.cwd(), fullPath).replace(/\\/g, "/");
322
+ const fullPath = join(dirPath, entry.name);
323
+ const relativePath = relative(process.cwd(), fullPath).replace(/\\/g, "/");
269
324
 
270
325
  if (entry.isDirectory()) {
271
326
  await convertCRLFtoLF(fullPath, config);
@@ -288,30 +343,46 @@ async function convertCRLFtoLF(dirPath, config) {
288
343
  * @throws {Error} - if there's an error reading or writing file
289
344
  */
290
345
  async function processFile(filePath) {
291
- try {
292
- const content = await fs.readFile(filePath, "utf8");
293
- const updatedContent = content.replace(/\r\n/g, "\n");
294
-
295
- if (content !== updatedContent) {
296
- /**
297
- * @todo V8 javascript engine with 32-bit system cannot handle more than 2GB file,
298
- * so I should use createReadStream and createWriteStream to handle large files
299
- */
300
- await fs.writeFile(filePath, updatedContent, "utf8");
301
- logger.info(`converted: ${filePath}`);
302
- } else {
303
- logger.info(`no need to convert: ${filePath}`, filePath);
346
+ const tmpPath = `${filePath}.tmp`;
347
+ const crlf2lf = new Transform({
348
+ transform(chunk, encoding, callback) {
349
+ const enc = encoding === 'buffer' ? 'utf8' : encoding;
350
+ const prev = this._leftover ?? '';
351
+ this._leftover = '';
352
+ const str = prev + chunk.toString(enc);
353
+ const safe = str.endsWith('\r') ? str.slice(0, -1) : str;
354
+ this._leftover = str.endsWith('\r') ? '\r' : '';
355
+ callback(null, safe.replace(/\r\n/g, '\n'));
356
+ },
357
+ flush(callback) {
358
+ callback(null, this._leftover ?? '');
304
359
  }
360
+ });
361
+ try {
362
+ await pipeline(
363
+ createReadStream(filePath, { encoding: 'utf8' }),
364
+ crlf2lf,
365
+ createWriteStream(tmpPath, { encoding: 'utf8' })
366
+ );
367
+ logger.info(`converted ${filePath}`);
305
368
  } catch (err) {
306
369
  logger.error(`error processing file: ${filePath}`, filePath, err);
307
370
  throw err;
308
371
  }
372
+ try {
373
+ await rename(tmpPath, filePath);
374
+ } catch (err) {
375
+ logger.error(`error rename file: ${tmpPath} to ${filePath}`);
376
+ throw err;
377
+ }
309
378
  }
310
379
 
311
380
  async function main() {
312
381
  const options = parseArgs();
313
382
  const config = await resolveConfig(options);
314
383
 
384
+ logger.setLogLevel(config.logLevel);
385
+
315
386
  logger.info(`converting CRLF to LF in: ${config.entry}`, config.entry);
316
387
 
317
388
  await convertCRLFtoLF(config.entry, config);
@@ -0,0 +1,129 @@
1
+ const fs = require('fs').promises;
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { resolveConfig, convertCRLFtoLF } = require('./index.cjs');
5
+
6
+ const FIXTURES_DIR = path.join(__dirname, '__fixtures__');
7
+ const CRLF = Buffer.from([0x0d, 0x0a]);
8
+
9
+ /**
10
+ * Recursively copy a directory from src to dest.
11
+ * @param {string} src - source directory
12
+ * @param {string} dest - destination directory
13
+ */
14
+ async function copyDir(src, dest) {
15
+ await fs.mkdir(dest, { recursive: true });
16
+ const entries = await fs.readdir(src, { withFileTypes: true });
17
+ for (const entry of entries) {
18
+ const srcPath = path.join(src, entry.name);
19
+ const destPath = path.join(dest, entry.name);
20
+ if (entry.isDirectory()) {
21
+ await copyDir(srcPath, destPath);
22
+ } else {
23
+ await fs.copyFile(srcPath, destPath);
24
+ }
25
+ }
26
+ }
27
+
28
+ describe('E2E: CRLF to LF with real filesystem', () => {
29
+ let tempDir;
30
+ let originalCwd;
31
+
32
+ beforeEach(async () => {
33
+ originalCwd = process.cwd();
34
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lfify-e2e-'));
35
+ });
36
+
37
+ afterEach(async () => {
38
+ try {
39
+ process.chdir(originalCwd);
40
+ if (tempDir) {
41
+ await fs.rm(tempDir, { recursive: true, force: true });
42
+ }
43
+ } catch {
44
+ // best-effort cleanup
45
+ }
46
+ });
47
+
48
+ describe('US1: default run (no config)', () => {
49
+ it('converts files under entry and excludes node_modules and .git', async () => {
50
+ await copyDir(path.join(FIXTURES_DIR, 'default-sensible'), tempDir);
51
+ await fs.rename(path.join(tempDir, '_git'), path.join(tempDir, '.git'));
52
+ await fs.rename(path.join(tempDir, '_node_modules'), path.join(tempDir, 'node_modules'));
53
+ process.chdir(tempDir);
54
+
55
+ const config = await resolveConfig({});
56
+ await convertCRLFtoLF(config.entry, config);
57
+
58
+ const appJs = await fs.readFile(path.join(tempDir, 'src', 'app.js'));
59
+ expect(appJs.includes(CRLF)).toBe(false);
60
+ expect(appJs.equals(Buffer.from('console.log("app");\n', 'utf8'))).toBe(true);
61
+
62
+ const readmeTxt = await fs.readFile(path.join(tempDir, 'src', 'readme.txt'));
63
+ expect(readmeTxt.includes(CRLF)).toBe(false);
64
+ expect(readmeTxt.equals(Buffer.from('hello\nworld\n', 'utf8'))).toBe(true);
65
+
66
+ const nodeModulesPkg = await fs.readFile(
67
+ path.join(tempDir, 'node_modules', 'pkg', 'index.js')
68
+ );
69
+ expect(nodeModulesPkg.includes(CRLF)).toBe(true);
70
+
71
+ const gitConfig = await fs.readFile(path.join(tempDir, '.git', 'config'));
72
+ expect(gitConfig.includes(CRLF)).toBe(true);
73
+ });
74
+ });
75
+
76
+ describe('US2: .lfifyrc.json', () => {
77
+ it('applies config include/exclude so only matching files are converted', async () => {
78
+ await copyDir(path.join(FIXTURES_DIR, 'with-config'), tempDir);
79
+ process.chdir(tempDir);
80
+
81
+ const config = await resolveConfig({ configPath: '.lfifyrc.json' });
82
+ await convertCRLFtoLF(config.entry, config);
83
+
84
+ const mainJs = await fs.readFile(path.join(tempDir, 'lib', 'main.js'));
85
+ expect(mainJs.includes(CRLF)).toBe(false);
86
+ expect(mainJs.equals(Buffer.from('const x = 1;\n', 'utf8'))).toBe(true);
87
+
88
+ const skipOtherJs = await fs.readFile(path.join(tempDir, 'skip', 'other.js'));
89
+ expect(skipOtherJs.includes(CRLF)).toBe(true);
90
+
91
+ const docTxt = await fs.readFile(path.join(tempDir, 'doc.txt'));
92
+ expect(docTxt.includes(CRLF)).toBe(true);
93
+ });
94
+ });
95
+
96
+ describe('US3: CLI overrides config', () => {
97
+ it('CLI include overrides config so only .js files are converted', async () => {
98
+ await copyDir(path.join(FIXTURES_DIR, 'cli-override'), tempDir);
99
+ process.chdir(tempDir);
100
+
101
+ const config = await resolveConfig({
102
+ configPath: '.lfifyrc.json',
103
+ include: ['**/*.js']
104
+ });
105
+ await convertCRLFtoLF(config.entry, config);
106
+
107
+ const aJs = await fs.readFile(path.join(tempDir, 'src', 'a.js'));
108
+ expect(aJs.includes(CRLF)).toBe(false);
109
+ expect(aJs.equals(Buffer.from('const a = 1;\n', 'utf8'))).toBe(true);
110
+
111
+ const bTxt = await fs.readFile(path.join(tempDir, 'src', 'b.txt'));
112
+ expect(bTxt.includes(CRLF)).toBe(true);
113
+ });
114
+ });
115
+
116
+ describe('US4: already LF', () => {
117
+ it('does not modify files that already use LF', async () => {
118
+ await copyDir(path.join(FIXTURES_DIR, 'already-lf'), tempDir);
119
+ process.chdir(tempDir);
120
+
121
+ const config = await resolveConfig({});
122
+ await convertCRLFtoLF(config.entry, config);
123
+
124
+ const fileJs = await fs.readFile(path.join(tempDir, 'src', 'file.js'));
125
+ expect(fileJs.includes(CRLF)).toBe(false);
126
+ expect(fileJs.equals(Buffer.from('const x = 1;\n', 'utf8'))).toBe(true);
127
+ });
128
+ });
129
+ });
package/index.test.js CHANGED
@@ -1,48 +1,46 @@
1
+ const mock = require('mock-fs');
1
2
  const { readConfig, parseArgs, processFile, resolveConfig, shouldProcessFile, SENSIBLE_DEFAULTS } = require('./index.cjs');
2
-
3
- jest.mock('fs');
4
- jest.mock('path');
5
- jest.mock('micromatch');
6
-
7
- describe('CRLF to LF Converter', () => {
8
- const MOCK_FILE_INFO = {
9
- './src/file1.txt': 'hello\r\nworld\r\n',
10
- './src/file2.js': 'console.log("test");\r\n',
11
- './src/subdir/file3.txt': 'test\r\n',
12
- './test/file1.txt': 'hello\r\nworld\r\n',
13
- './test/file2.js': 'console.log("test");\r\n',
14
- './test/subdir/file3.txt': 'test\r\n',
15
- './node_modules/file.js': 'console.log("test");\r\n',
16
- './node_modules/subdir/file4.txt': 'test\r\n',
17
- 'index.js': 'console.log("test");\r\n'
3
+ const fs = require('fs');
4
+
5
+ function baseMock(overrides = {}) {
6
+ return {
7
+ 'src/file1.txt': 'hello\r\nworld\r\n',
8
+ 'src/file2.js': 'console.log("test");\r\n',
9
+ 'src/subdir/file3.txt': 'test\r\n',
10
+ 'test/file1.txt': 'hello\r\nworld\r\n',
11
+ 'test/file2.js': 'console.log("test");\r\n',
12
+ 'test/subdir/file3.txt': 'test\r\n',
13
+ 'node_modules/file.js': 'console.log("test");\r\n',
14
+ 'node_modules/subdir/file4.txt': 'test\r\n',
15
+ 'index.js': 'console.log("test");\r\n',
16
+ ...overrides
18
17
  };
18
+ }
19
19
 
20
+ describe('CRLF to LF Converter', () => {
20
21
  let originalArgv;
21
22
 
22
23
  beforeEach(() => {
23
- jest.clearAllMocks();
24
- require('fs').__setMockFiles(MOCK_FILE_INFO);
24
+ mock(baseMock());
25
25
  originalArgv = process.argv;
26
26
  });
27
27
 
28
28
  afterEach(() => {
29
+ mock.restore();
29
30
  process.argv = originalArgv;
30
31
  });
31
32
 
32
33
  describe('readConfig', () => {
33
34
  it('should return config when valid config file is provided', async () => {
34
- // arrange
35
35
  const validConfig = {
36
36
  entry: './',
37
37
  include: ['*.js'],
38
38
  exclude: ['node_modules/**']
39
39
  };
40
- require('fs').__setConfig(JSON.stringify(validConfig));
40
+ mock(baseMock({ '.lfifyrc.json': JSON.stringify(validConfig) }));
41
41
 
42
- // act
43
42
  const config = await readConfig('.lfifyrc.json');
44
43
 
45
- // assert
46
44
  expect(config).toEqual(expect.objectContaining({
47
45
  entry: expect.any(String),
48
46
  include: expect.any(Array),
@@ -51,39 +49,30 @@ describe('CRLF to LF Converter', () => {
51
49
  });
52
50
 
53
51
  it('should throw error when config file is not found', async () => {
54
- // act & assert
55
52
  await expect(readConfig('.lfifyrc.json')).rejects.toThrow();
56
53
  });
57
54
 
58
55
  it('should throw error when config file is invalid json', async () => {
59
- // arrange
60
- require('fs').__setConfig('invalid json');
56
+ mock(baseMock({ '.lfifyrc.json': 'invalid json' }));
61
57
 
62
- // act & assert
63
58
  await expect(readConfig('.lfifyrc.json')).rejects.toThrow();
64
59
  });
65
60
  });
66
61
 
67
62
  describe('parseArgs', () => {
68
63
  it('should return config path when --config option is provided', () => {
69
- // arrange
70
64
  process.argv = ['node', 'lfify', '--config', './path/for/test/.lfifyrc.json'];
71
65
 
72
- // act
73
66
  const options = parseArgs();
74
67
 
75
- // assert
76
68
  expect(options.configPath).toBe('./path/for/test/.lfifyrc.json');
77
69
  });
78
70
 
79
71
  it('should return default config path when --config option is not provided', () => {
80
- // arrange
81
72
  process.argv = ['node', 'lfify'];
82
73
 
83
- // act
84
74
  const options = parseArgs();
85
75
 
86
- // assert
87
76
  expect(options.configPath).toBe('.lfifyrc.json');
88
77
  });
89
78
 
@@ -106,33 +95,25 @@ describe('CRLF to LF Converter', () => {
106
95
  });
107
96
 
108
97
  it('should return multiple exclude patterns when multiple --exclude options are provided', () => {
109
- process.argv = ['node', 'lfify', '--exclude', 'node_modules/**', '--exclude', '.git/**'];
98
+ process.argv = ['node', 'lfify', '--exclude', 'dist/**', '--exclude', 'coverage/**'];
110
99
  const options = parseArgs();
111
- expect(options.exclude).toEqual(['node_modules/**', '.git/**']);
100
+ expect(options.exclude).toEqual(['dist/**', 'coverage/**']);
112
101
  });
113
102
 
114
103
  it('should return entry path when --entry option is provided', () => {
115
104
  process.argv = ['node', 'lfify', '--entry', './src'];
116
105
  const options = parseArgs();
117
- expect(options.entry).toBe('./src');
106
+ expect(options.entry).toContain('src');
118
107
  });
119
108
 
120
109
  it('should handle all options together', () => {
121
- process.argv = [
122
- 'node', 'lfify',
123
- '--entry', './src',
124
- '--include', '**/*.js',
125
- '--include', '**/*.ts',
126
- '--exclude', 'node_modules/**',
127
- '--config', 'custom.json'
128
- ];
110
+ process.argv = ['node', 'lfify', '--config', 'custom.json', '--include', '*.js', '--exclude', 'dist/**', '--entry', './lib', '--log-level', 'info'];
129
111
  const options = parseArgs();
130
- expect(options).toEqual({
131
- configPath: 'custom.json',
132
- entry: './src',
133
- include: ['**/*.js', '**/*.ts'],
134
- exclude: ['node_modules/**']
135
- });
112
+ expect(options.configPath).toBe('custom.json');
113
+ expect(options.include).toEqual(['*.js']);
114
+ expect(options.exclude).toEqual(['dist/**']);
115
+ expect(options.entry).toContain('lib');
116
+ expect(options.logLevel).toBe('info');
136
117
  });
137
118
 
138
119
  it('should return undefined for include/exclude/entry when not provided', () => {
@@ -142,77 +123,68 @@ describe('CRLF to LF Converter', () => {
142
123
  expect(options.exclude).toBeUndefined();
143
124
  expect(options.entry).toBeUndefined();
144
125
  });
126
+
127
+ it('should return logLevel when --log-level option is provided', () => {
128
+ process.argv = ['node', 'lfify', '--log-level', 'warn'];
129
+ const options = parseArgs();
130
+ expect(options.logLevel).toBe('warn');
131
+ });
132
+
133
+ it('should accept error, warn, info for --log-level', () => {
134
+ for (const level of ['error', 'warn', 'info']) {
135
+ process.argv = ['node', 'lfify', '--log-level', level];
136
+ expect(parseArgs().logLevel).toBe(level);
137
+ }
138
+ });
145
139
  });
146
140
 
147
141
  describe('shouldProcessFile', () => {
148
142
  it('should return true when file matches include pattern and does not match exclude pattern', () => {
149
- const config = {
150
- include: ['**/*.js'],
151
- exclude: ['node_modules/**']
152
- };
153
- expect(shouldProcessFile('src/file.js', config)).toBe(true);
143
+ const config = { include: ['**/*.js'], exclude: ['node_modules/**'] };
144
+ expect(shouldProcessFile('src/app.js', config)).toBe(true);
154
145
  });
155
146
 
156
147
  it('should return false when file matches exclude pattern', () => {
157
- const config = {
158
- include: ['**/*.js'],
159
- exclude: ['node_modules/**']
160
- };
161
- expect(shouldProcessFile('node_modules/package/index.js', config)).toBe(false);
148
+ const config = { include: ['**/*.js'], exclude: ['node_modules/**'] };
149
+ expect(shouldProcessFile('node_modules/pkg/index.js', config)).toBe(false);
162
150
  });
163
151
 
164
152
  it('should return false when file does not match include pattern', () => {
165
- const config = {
166
- include: ['**/*.js'],
167
- exclude: ['node_modules/**']
168
- };
169
- expect(shouldProcessFile('src/file.txt', config)).toBe(false);
153
+ const config = { include: ['**/*.js'], exclude: ['node_modules/**'] };
154
+ expect(shouldProcessFile('src/readme.txt', config)).toBe(false);
170
155
  });
171
156
 
172
157
  it('should handle multiple include patterns', () => {
173
- const config = {
174
- include: ['**/*.js', '**/*.ts'],
175
- exclude: []
176
- };
177
- expect(shouldProcessFile('src/file.js', config)).toBe(true);
178
- expect(shouldProcessFile('src/file.ts', config)).toBe(true);
179
- expect(shouldProcessFile('src/file.txt', config)).toBe(false);
158
+ const config = { include: ['**/*.js', '**/*.ts'], exclude: ['node_modules/**'] };
159
+ expect(shouldProcessFile('src/app.js', config)).toBe(true);
160
+ expect(shouldProcessFile('src/app.ts', config)).toBe(true);
180
161
  });
181
162
 
182
163
  it('should handle multiple exclude patterns', () => {
183
- const config = {
184
- include: ['**/*.js'],
185
- exclude: ['node_modules/**', 'dist/**', 'test/**']
186
- };
164
+ const config = { include: ['**/*.js'], exclude: ['node_modules/**', 'dist/**', 'build/**', 'coverage/**'] };
187
165
  expect(shouldProcessFile('src/file.js', config)).toBe(true);
188
- expect(shouldProcessFile('node_modules/pkg/index.js', config)).toBe(false);
166
+ expect(shouldProcessFile('node_modules/pkg/file.js', config)).toBe(false);
189
167
  expect(shouldProcessFile('dist/bundle.js', config)).toBe(false);
190
- expect(shouldProcessFile('test/unit.js', config)).toBe(false);
168
+ expect(shouldProcessFile('test/unit.js', config)).toBe(true);
191
169
  });
192
170
  });
193
171
 
194
172
  describe('processFile', () => {
195
173
  it('should convert CRLF to LF when file is processed', async () => {
196
- // arrange
197
174
  const shouldbe = 'hello\nworld\n';
198
175
 
199
- // act
200
176
  await processFile('./src/file1.txt');
201
- const content = await require('fs').promises.readFile('./src/file1.txt', 'utf8');
177
+ const content = await fs.promises.readFile('./src/file1.txt', 'utf8');
202
178
 
203
- // assert
204
179
  expect(content).toBe(shouldbe);
205
180
  });
206
181
 
207
182
  it('should not modify file when no CRLF exists', async () => {
208
- // arrange
209
- require('fs').__setMockFiles({ './src/clean.txt': 'hello\nworld\n' });
183
+ mock(baseMock({ 'src/clean.txt': 'hello\nworld\n' }));
210
184
 
211
- // act
212
185
  await processFile('./src/clean.txt');
213
- const content = await require('fs').promises.readFile('./src/clean.txt', 'utf8');
186
+ const content = await fs.promises.readFile('./src/clean.txt', 'utf8');
214
187
 
215
- // assert
216
188
  expect(content).toBe('hello\nworld\n');
217
189
  });
218
190
  });
@@ -239,27 +211,31 @@ describe('CRLF to LF Converter', () => {
239
211
  });
240
212
 
241
213
  it('should override config file values with CLI options', async () => {
242
- require('fs').__setConfig(JSON.stringify({
243
- entry: './',
244
- include: ['**/*.md'],
245
- exclude: ['dist/**']
214
+ mock(baseMock({
215
+ '.lfifyrc.json': JSON.stringify({
216
+ entry: './',
217
+ include: ['**/*.md'],
218
+ exclude: ['dist/**']
219
+ })
246
220
  }));
247
221
 
248
222
  const options = {
249
223
  configPath: '.lfifyrc.json',
250
- include: ['**/*.js'] // CLI should override config
224
+ include: ['**/*.js']
251
225
  };
252
226
  const config = await resolveConfig(options);
253
227
 
254
- expect(config.include).toEqual(['**/*.js']); // CLI override
255
- expect(config.exclude).toEqual(['dist/**']); // from config file
228
+ expect(config.include).toEqual(['**/*.js']);
229
+ expect(config.exclude).toEqual(['dist/**']);
256
230
  });
257
231
 
258
232
  it('should load config file when configPath is provided and file exists', async () => {
259
- require('fs').__setConfig(JSON.stringify({
260
- entry: './lib',
261
- include: ['**/*.ts'],
262
- exclude: ['test/**']
233
+ mock(baseMock({
234
+ '.lfifyrc.json': JSON.stringify({
235
+ entry: './lib',
236
+ include: ['**/*.ts'],
237
+ exclude: ['test/**']
238
+ })
263
239
  }));
264
240
 
265
241
  const options = { configPath: '.lfifyrc.json' };
@@ -270,7 +246,6 @@ describe('CRLF to LF Converter', () => {
270
246
  });
271
247
 
272
248
  it('should use defaults when config file not found and no CLI options', async () => {
273
- // No config file set up - mock will throw ENOENT
274
249
  const options = { configPath: 'nonexistent.json' };
275
250
  const config = await resolveConfig(options);
276
251
 
@@ -293,5 +268,39 @@ describe('CRLF to LF Converter', () => {
293
268
  expect(config.include).toEqual(SENSIBLE_DEFAULTS.include);
294
269
  expect(config.exclude).toEqual(['custom/**']);
295
270
  });
271
+
272
+ it('should include logLevel in config, defaulting to error', async () => {
273
+ const config = await resolveConfig({});
274
+ expect(config.logLevel).toBe('error');
275
+ });
276
+
277
+ it('should use logLevel from config file when provided', async () => {
278
+ mock(baseMock({
279
+ '.lfifyrc.json': JSON.stringify({
280
+ entry: './',
281
+ include: ['**/*.js'],
282
+ exclude: ['node_modules/**'],
283
+ logLevel: 'info'
284
+ })
285
+ }));
286
+ const config = await resolveConfig({ configPath: '.lfifyrc.json' });
287
+ expect(config.logLevel).toBe('info');
288
+ });
289
+
290
+ it('should override config file logLevel with CLI --log-level', async () => {
291
+ mock(baseMock({
292
+ '.lfifyrc.json': JSON.stringify({
293
+ entry: './',
294
+ include: ['**/*.js'],
295
+ exclude: ['node_modules/**'],
296
+ logLevel: 'warn'
297
+ })
298
+ }));
299
+ const config = await resolveConfig({
300
+ configPath: '.lfifyrc.json',
301
+ logLevel: 'info'
302
+ });
303
+ expect(config.logLevel).toBe('info');
304
+ });
296
305
  });
297
- });
306
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lfify",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "description": "make your crlf to lf",
6
6
  "main": "index.cjs",
@@ -9,7 +9,9 @@
9
9
  "lfify": "./index.cjs"
10
10
  },
11
11
  "scripts": {
12
- "test": "jest",
12
+ "test": "npm run test:unit && npm run test:e2e",
13
+ "test:unit": "jest --testPathPattern=\"index\\.test\\.js\"",
14
+ "test:e2e": "jest --testPathPattern=\"index\\.e2e\\.test\\.js\"",
13
15
  "lint": "eslint ."
14
16
  },
15
17
  "repository": {
@@ -17,7 +19,8 @@
17
19
  "url": "https://github.com/GyeongHoKim/lfify.git"
18
20
  },
19
21
  "publishConfig": {
20
- "registry": "https://registry.npmjs.org",
22
+ "registry": "https://registry.npmjs.org/",
23
+ "provenance": true,
21
24
  "tag": "latest"
22
25
  },
23
26
  "keywords": [
@@ -44,6 +47,7 @@
44
47
  "eslint": "^9.15.0",
45
48
  "globals": "^15.12.0",
46
49
  "jest": "^29.7.0",
50
+ "mock-fs": "^5.5.0",
47
51
  "semantic-release": "^25.0.2"
48
52
  }
49
53
  }
package/__mocks__/fs.js DELETED
@@ -1,56 +0,0 @@
1
- const fs = jest.createMockFromModule('fs');
2
-
3
- const mockFiles = new Map();
4
-
5
- function __setMockFiles(newMockFiles) {
6
- mockFiles.clear();
7
- for (const [path, content] of Object.entries(newMockFiles)) {
8
- mockFiles.set(path, content);
9
- }
10
- }
11
-
12
- function __setConfig(stringifiedConfig, path = '.lfifyrc.json') {
13
- mockFiles.set(path, stringifiedConfig);
14
- }
15
-
16
- const promises = {
17
- /* eslint-disable-next-line no-unused-vars */
18
- readFile: jest.fn().mockImplementation((path, ...rest) => {
19
- if (mockFiles.has(path)) {
20
- return Promise.resolve(mockFiles.get(path));
21
- }
22
- const error = new Error(`ENOENT: no such file or directory, open '${path}'`);
23
- error.code = 'ENOENT';
24
- return Promise.reject(error);
25
- }),
26
-
27
- writeFile: jest.fn().mockImplementation((path, content) => {
28
- mockFiles.set(path, content);
29
- return Promise.resolve();
30
- }),
31
-
32
- /* eslint-disable-next-line no-unused-vars */
33
- readdir: jest.fn().mockImplementation((path, ...rest) => {
34
- const entries = [];
35
- for (const filePath of mockFiles.keys()) {
36
- if (filePath.startsWith(path)) {
37
- const relativePath = filePath.slice(path.length + 1);
38
- const name = relativePath.split('/')[0];
39
- if (name && !entries.some(e => e.name === name)) {
40
- entries.push({
41
- name,
42
- isFile: () => !name.includes('/'),
43
- isDirectory: () => name.includes('/')
44
- });
45
- }
46
- }
47
- }
48
- return Promise.resolve(entries);
49
- })
50
- };
51
-
52
- fs.promises = promises;
53
- fs.__setMockFiles = __setMockFiles;
54
- fs.__setConfig = __setConfig;
55
-
56
- module.exports = fs;
@@ -1,39 +0,0 @@
1
- const micromatch = jest.createMockFromModule('micromatch');
2
-
3
- micromatch.isMatch = jest.fn().mockImplementation((filePath, patterns) => {
4
- if (!Array.isArray(patterns)) {
5
- patterns = [patterns];
6
- }
7
-
8
- // 간단한 glob 패턴 매칭 구현
9
- return patterns.some(pattern => {
10
- // 정확한 매칭
11
- if (pattern === filePath) return true;
12
-
13
- // glob 패턴을 정규식으로 변환
14
- // 플레이스홀더를 사용해서 순서 문제 해결
15
- const GLOBSTAR_SLASH = '___GLOBSTARSLASH___';
16
- const GLOBSTAR = '___GLOBSTAR___';
17
-
18
- let regexPattern = pattern
19
- // **/ 패턴을 플레이스홀더로 임시 변환
20
- .replace(/\*\*\//g, GLOBSTAR_SLASH)
21
- // ** 패턴을 플레이스홀더로 임시 변환
22
- .replace(/\*\*/g, GLOBSTAR)
23
- // {a,b} 패턴
24
- .replace(/\{([^}]+)\}/g, (_, group) => `(${group.split(',').join('|')})`)
25
- // 특수문자 이스케이프 (*, ?, / 제외)
26
- .replace(/[.+^$|()[\]\\]/g, '\\$&')
27
- // * 패턴: 슬래시를 제외한 모든 것 매칭
28
- .replace(/\*/g, '[^/]*')
29
- // ? 패턴: 슬래시를 제외한 한 문자 매칭
30
- .replace(/\?/g, '[^/]')
31
- // 플레이스홀더를 실제 정규식으로 변환
32
- .replace(new RegExp(GLOBSTAR_SLASH, 'g'), '(?:.*/)?')
33
- .replace(new RegExp(GLOBSTAR, 'g'), '.*');
34
-
35
- return new RegExp(`^${regexPattern}$`).test(filePath);
36
- });
37
- });
38
-
39
- module.exports = micromatch;