lfify 1.1.1 → 1.2.1

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.
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, unlink } = 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
  /**
@@ -43,13 +53,8 @@ const DEFAULT_CONFIG = {
43
53
  const SENSIBLE_DEFAULTS = {
44
54
  entry: './',
45
55
  include: ['**/*'],
46
- exclude: [
47
- 'node_modules/**',
48
- '.git/**',
49
- 'dist/**',
50
- 'build/**',
51
- 'coverage/**'
52
- ]
56
+ exclude: ['node_modules/**', '.git/**', 'dist/**', 'build/**', 'coverage/**'],
57
+ logLevel: 'error',
53
58
  };
54
59
 
55
60
  /**
@@ -58,18 +63,49 @@ const SENSIBLE_DEFAULTS = {
58
63
  */
59
64
  const CONFIG_SCHEMA = {
60
65
  entry: (value) => typeof value === 'string',
61
- include: (value) => Array.isArray(value) && value.length > 0 && value.every(item => typeof item === 'string'),
62
- exclude: (value) => Array.isArray(value) && value.length > 0 && value.every(item => typeof item === 'string'),
66
+ include: (value) =>
67
+ Array.isArray(value) &&
68
+ value.length > 0 &&
69
+ value.every((item) => typeof item === 'string'),
70
+ exclude: (value) =>
71
+ Array.isArray(value) &&
72
+ value.length > 0 &&
73
+ 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,15 +129,18 @@ 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') {
100
136
  logger.error(`Configuration file not found: ${configPath}`, configPath);
101
137
  } else {
102
- logger.error(`Error reading configuration file: ${err.message}`, configPath);
138
+ logger.error(
139
+ `Error reading configuration file: ${err.message}`,
140
+ configPath,
141
+ );
103
142
  }
104
-
143
+
105
144
  if (require.main === module) {
106
145
  process.exit(1);
107
146
  }
@@ -120,7 +159,7 @@ async function resolveConfig(cliOptions) {
120
159
  // Try to load config file if it exists
121
160
  if (cliOptions.configPath) {
122
161
  try {
123
- const configContent = await fs.readFile(cliOptions.configPath, 'utf8');
162
+ const configContent = await readFile(cliOptions.configPath, 'utf8');
124
163
  fileConfig = JSON.parse(configContent);
125
164
 
126
165
  // Validate config file fields
@@ -132,7 +171,10 @@ async function resolveConfig(cliOptions) {
132
171
  } catch (err) {
133
172
  if (err.code !== 'ENOENT') {
134
173
  // Re-throw parsing/validation errors
135
- logger.error(`Error reading configuration file: ${err.message}`, cliOptions.configPath);
174
+ logger.error(
175
+ `Error reading configuration file: ${err.message}`,
176
+ cliOptions.configPath,
177
+ );
136
178
  throw err;
137
179
  }
138
180
  // ENOENT is okay - config file is optional now
@@ -140,17 +182,32 @@ async function resolveConfig(cliOptions) {
140
182
  }
141
183
 
142
184
  // Determine final values with precedence: CLI > config file > defaults
143
- const hasCLIInclude = Array.isArray(cliOptions.include) && cliOptions.include.length > 0;
144
- const hasCLIExclude = Array.isArray(cliOptions.exclude) && cliOptions.exclude.length > 0;
185
+ const hasCLIInclude =
186
+ Array.isArray(cliOptions.include) && cliOptions.include.length > 0;
187
+ const hasCLIExclude =
188
+ Array.isArray(cliOptions.exclude) && cliOptions.exclude.length > 0;
145
189
  const hasCLIEntry = typeof cliOptions.entry === 'string';
190
+ const hasCLILogLevel =
191
+ typeof cliOptions.logLevel === 'string' &&
192
+ LOG_LEVELS.includes(cliOptions.logLevel);
146
193
 
147
194
  const hasFileConfig = fileConfig !== null;
148
- const hasFileInclude = hasFileConfig && Array.isArray(fileConfig.include) && fileConfig.include.length > 0;
149
- const hasFileExclude = hasFileConfig && Array.isArray(fileConfig.exclude) && fileConfig.exclude.length > 0;
195
+ const hasFileInclude =
196
+ hasFileConfig &&
197
+ Array.isArray(fileConfig.include) &&
198
+ fileConfig.include.length > 0;
199
+ const hasFileExclude =
200
+ hasFileConfig &&
201
+ Array.isArray(fileConfig.exclude) &&
202
+ fileConfig.exclude.length > 0;
150
203
  const hasFileEntry = hasFileConfig && typeof fileConfig.entry === 'string';
204
+ const hasFileLogLevel =
205
+ hasFileConfig &&
206
+ fileConfig.logLevel &&
207
+ LOG_LEVELS.includes(fileConfig.logLevel);
151
208
 
152
209
  // Resolve each config property
153
- let include, exclude, entry;
210
+ let include, exclude, entry, logLevel;
154
211
 
155
212
  // Include: CLI > file > default
156
213
  if (hasCLIInclude) {
@@ -179,10 +236,20 @@ async function resolveConfig(cliOptions) {
179
236
  entry = SENSIBLE_DEFAULTS.entry;
180
237
  }
181
238
 
239
+ // LogLevel: CLI > file > default
240
+ if (hasCLILogLevel) {
241
+ logLevel = cliOptions.logLevel;
242
+ } else if (hasFileLogLevel) {
243
+ logLevel = fileConfig.logLevel;
244
+ } else {
245
+ logLevel = SENSIBLE_DEFAULTS.logLevel;
246
+ }
247
+
182
248
  return {
183
- entry: path.resolve(process.cwd(), entry),
249
+ entry: resolve(process.cwd(), entry),
184
250
  include,
185
- exclude
251
+ exclude,
252
+ logLevel,
186
253
  };
187
254
  }
188
255
 
@@ -193,7 +260,7 @@ async function resolveConfig(cliOptions) {
193
260
  function parseArgs() {
194
261
  const args = process.argv.slice(2);
195
262
  const options = {
196
- configPath: '.lfifyrc.json'
263
+ configPath: '.lfifyrc.json',
197
264
  };
198
265
 
199
266
  for (let i = 0; i < args.length; i++) {
@@ -230,6 +297,13 @@ function parseArgs() {
230
297
  i++;
231
298
  }
232
299
  break;
300
+
301
+ case '--log-level':
302
+ if (nextArg && LOG_LEVELS.includes(nextArg)) {
303
+ options.logLevel = nextArg;
304
+ i++;
305
+ }
306
+ break;
233
307
  }
234
308
  }
235
309
 
@@ -243,8 +317,8 @@ function parseArgs() {
243
317
  * @returns {boolean} - true if file should be processed
244
318
  */
245
319
  function shouldProcessFile(filePath, config) {
246
- const isIncluded = micromatch.isMatch(filePath, config.include);
247
- const isExcluded = micromatch.isMatch(filePath, config.exclude);
320
+ const isIncluded = isMatch(filePath, config.include);
321
+ const isExcluded = isMatch(filePath, config.exclude);
248
322
 
249
323
  return isIncluded && !isExcluded;
250
324
  }
@@ -258,23 +332,28 @@ function shouldProcessFile(filePath, config) {
258
332
  */
259
333
  async function convertCRLFtoLF(dirPath, config) {
260
334
  try {
261
- const entries = await fs.readdir(dirPath, { withFileTypes: true });
335
+ const entries = await readdir(dirPath, { withFileTypes: true });
262
336
 
263
337
  /**
264
338
  * @todo Node.js is single-threaded, if I want to convert files in parallel, I need to use worker_threads
265
339
  */
266
- 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, "/");
269
-
270
- if (entry.isDirectory()) {
271
- await convertCRLFtoLF(fullPath, config);
272
- } else if (entry.isFile() && shouldProcessFile(relativePath, config)) {
273
- await processFile(fullPath);
274
- } else {
275
- logger.info(`skipped: ${relativePath}`, fullPath);
276
- }
277
- }));
340
+ await Promise.all(
341
+ entries.map(async (entry) => {
342
+ const fullPath = join(dirPath, entry.name);
343
+ const relativePath = relative(process.cwd(), fullPath).replace(
344
+ /\\/g,
345
+ '/',
346
+ );
347
+
348
+ if (entry.isDirectory()) {
349
+ await convertCRLFtoLF(fullPath, config);
350
+ } else if (entry.isFile() && shouldProcessFile(relativePath, config)) {
351
+ await processFile(fullPath);
352
+ } else {
353
+ logger.info(`skipped: ${relativePath}`, fullPath);
354
+ }
355
+ }),
356
+ );
278
357
  } catch (err) {
279
358
  logger.error(`error reading directory: ${dirPath}`, dirPath, err);
280
359
  throw err;
@@ -288,35 +367,52 @@ async function convertCRLFtoLF(dirPath, config) {
288
367
  * @throws {Error} - if there's an error reading or writing file
289
368
  */
290
369
  async function processFile(filePath) {
370
+ const tmpPath = `${filePath}.tmp`;
371
+ const crlf2lf = new Transform({
372
+ transform(chunk, encoding, callback) {
373
+ const enc = encoding === 'buffer' ? 'utf8' : encoding;
374
+ const prev = this._leftover ?? '';
375
+ this._leftover = '';
376
+ const str = prev + chunk.toString(enc);
377
+ const safe = str.endsWith('\r') ? str.slice(0, -1) : str;
378
+ this._leftover = str.endsWith('\r') ? '\r' : '';
379
+ callback(null, safe.replace(/\r\n/g, '\n'));
380
+ },
381
+ flush(callback) {
382
+ callback(null, this._leftover ?? '');
383
+ },
384
+ });
291
385
  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);
304
- }
386
+ await pipeline(
387
+ createReadStream(filePath, { encoding: 'utf8' }),
388
+ crlf2lf,
389
+ createWriteStream(tmpPath, { encoding: 'utf8' }),
390
+ );
391
+ logger.info(`converted ${filePath}`);
305
392
  } catch (err) {
306
393
  logger.error(`error processing file: ${filePath}`, filePath, err);
307
394
  throw err;
308
395
  }
396
+ try {
397
+ await rename(tmpPath, filePath);
398
+ } catch (err) {
399
+ logger.error(`error rename file: ${tmpPath} to ${filePath}`);
400
+ unlink(tmpPath);
401
+ throw err;
402
+ }
309
403
  }
310
404
 
311
405
  async function main() {
312
406
  const options = parseArgs();
313
407
  const config = await resolveConfig(options);
314
408
 
409
+ logger.setLogLevel(config.logLevel);
410
+
315
411
  logger.info(`converting CRLF to LF in: ${config.entry}`, config.entry);
316
412
 
317
413
  await convertCRLFtoLF(config.entry, config);
318
414
 
319
- logger.info("conversion completed.", config.entry);
415
+ logger.info('conversion completed.', config.entry);
320
416
  }
321
417
 
322
418
  if (require.main === module) {
@@ -0,0 +1,140 @@
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(
53
+ path.join(tempDir, '_node_modules'),
54
+ path.join(tempDir, 'node_modules'),
55
+ );
56
+ process.chdir(tempDir);
57
+
58
+ const config = await resolveConfig({});
59
+ await convertCRLFtoLF(config.entry, config);
60
+
61
+ const appJs = await fs.readFile(path.join(tempDir, 'src', 'app.js'));
62
+ expect(appJs.includes(CRLF)).toBe(false);
63
+ expect(appJs.equals(Buffer.from('console.log("app");\n', 'utf8'))).toBe(
64
+ true,
65
+ );
66
+
67
+ const readmeTxt = await fs.readFile(
68
+ path.join(tempDir, 'src', 'readme.txt'),
69
+ );
70
+ expect(readmeTxt.includes(CRLF)).toBe(false);
71
+ expect(readmeTxt.equals(Buffer.from('hello\nworld\n', 'utf8'))).toBe(
72
+ true,
73
+ );
74
+
75
+ const nodeModulesPkg = await fs.readFile(
76
+ path.join(tempDir, 'node_modules', 'pkg', 'index.js'),
77
+ );
78
+ expect(nodeModulesPkg.includes(CRLF)).toBe(true);
79
+
80
+ const gitConfig = await fs.readFile(path.join(tempDir, '.git', 'config'));
81
+ expect(gitConfig.includes(CRLF)).toBe(true);
82
+ });
83
+ });
84
+
85
+ describe('US2: .lfifyrc.json', () => {
86
+ it('applies config include/exclude so only matching files are converted', async () => {
87
+ await copyDir(path.join(FIXTURES_DIR, 'with-config'), tempDir);
88
+ process.chdir(tempDir);
89
+
90
+ const config = await resolveConfig({ configPath: '.lfifyrc.json' });
91
+ await convertCRLFtoLF(config.entry, config);
92
+
93
+ const mainJs = await fs.readFile(path.join(tempDir, 'lib', 'main.js'));
94
+ expect(mainJs.includes(CRLF)).toBe(false);
95
+ expect(mainJs.equals(Buffer.from('const x = 1;\n', 'utf8'))).toBe(true);
96
+
97
+ const skipOtherJs = await fs.readFile(
98
+ path.join(tempDir, 'skip', 'other.js'),
99
+ );
100
+ expect(skipOtherJs.includes(CRLF)).toBe(true);
101
+
102
+ const docTxt = await fs.readFile(path.join(tempDir, 'doc.txt'));
103
+ expect(docTxt.includes(CRLF)).toBe(true);
104
+ });
105
+ });
106
+
107
+ describe('US3: CLI overrides config', () => {
108
+ it('CLI include overrides config so only .js files are converted', async () => {
109
+ await copyDir(path.join(FIXTURES_DIR, 'cli-override'), tempDir);
110
+ process.chdir(tempDir);
111
+
112
+ const config = await resolveConfig({
113
+ configPath: '.lfifyrc.json',
114
+ include: ['**/*.js'],
115
+ });
116
+ await convertCRLFtoLF(config.entry, config);
117
+
118
+ const aJs = await fs.readFile(path.join(tempDir, 'src', 'a.js'));
119
+ expect(aJs.includes(CRLF)).toBe(false);
120
+ expect(aJs.equals(Buffer.from('const a = 1;\n', 'utf8'))).toBe(true);
121
+
122
+ const bTxt = await fs.readFile(path.join(tempDir, 'src', 'b.txt'));
123
+ expect(bTxt.includes(CRLF)).toBe(true);
124
+ });
125
+ });
126
+
127
+ describe('US4: already LF', () => {
128
+ it('does not modify files that already use LF', async () => {
129
+ await copyDir(path.join(FIXTURES_DIR, 'already-lf'), tempDir);
130
+ process.chdir(tempDir);
131
+
132
+ const config = await resolveConfig({});
133
+ await convertCRLFtoLF(config.entry, config);
134
+
135
+ const fileJs = await fs.readFile(path.join(tempDir, 'src', 'file.js'));
136
+ expect(fileJs.includes(CRLF)).toBe(false);
137
+ expect(fileJs.equals(Buffer.from('const x = 1;\n', 'utf8'))).toBe(true);
138
+ });
139
+ });
140
+ });