Repository: okonet/lint-staged
Pull request #1317 (2023-08-21 09:10:26)

fix: correctly read config from stdin
Fixes https://github.com/okonet/lint-staged/issues/1316, which was broken when switching to async `node:fs/promises`. This feature didn't have any tests, and I was lazy it seems...

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/okonet/lint-staged/pull/1317?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov) Report
Patch coverage: **`100.00%`** and no project coverage change.
> Comparison is base [(`9da8777`)](https://app.codecov.io/gh/okonet/lint-staged/commit/9da877711a7547b9122c6af91683fb2a2f398184?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov) 100.00% compared to head [(`4e14a22`)](https://app.codecov.io/gh/okonet/lint-staged/pull/1317?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov) 100.00%.

> :exclamation: Current head 4e14a22 differs from pull request most recent head 777713c. Consider uploading reports for the commit 777713c to get more accurate results

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##            master     #1317   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           27        28    +1     
  Lines          760       767    +7     
  Branches       199       199           
=========================================
+ Hits           760       767    +7     
```


| [Files Changed](https://app.codecov.io/gh/okonet/lint-staged/pull/1317?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov) | Coverage Δ | |
|---|---|---|
| [lib/messages.js](https://app.codecov.io/gh/okonet/lint-staged/pull/1317?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov#diff-bGliL21lc3NhZ2VzLmpz) | `100.00% <100.00%> (ø)` | |
| [lib/readStdin.js](https://app.codecov.io/gh/okonet/lint-staged/pull/1317?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov#diff-bGliL3JlYWRTdGRpbi5qcw==) | `100.00% <100.00%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/okonet/lint-staged/pull/1317?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov).

iiroj: Ping @okonet a fresh bug introduced in v14 by using `node:fs/promises` wrong.

Patch:
diff --git a/bin/lint-staged.js b/bin/lint-staged.js
index 4f4bff5..f1ae6ab 100755
--- a/bin/lint-staged.js
+++ b/bin/lint-staged.js
@@ -8,6 +8,7 @@ import debug from 'debug'
 
 import lintStaged from '../lib/index.js'
 import { CONFIG_STDIN_ERROR } from '../lib/messages.js'
+import { readStdin } from '../lib/readStdin.js'
 
 // Force colors for packages that depend on https://www.npmjs.com/package/supports-color
 if (supportsColor) {
@@ -110,17 +111,13 @@ debugLog('Options parsed from command-line:', options)
 if (options.configPath === '-') {
   delete options.configPath
   try {
-    options.config = await fs.readFile(process.stdin.fd, 'utf8').toString().trim()
-  } catch {
+    debugLog('Reading config from stdin')
+    options.config = JSON.parse(await readStdin())
+  } catch (error) {
+    debugLog(CONFIG_STDIN_ERROR, error)
     console.error(CONFIG_STDIN_ERROR)
     process.exit(1)
   }
-
-  try {
-    options.config = JSON.parse(options.config)
-  } catch {
-    // Let config parsing complain if it's not JSON
-  }
 }
 
 try {
diff --git a/lib/messages.js b/lib/messages.js
index 9308d20..06afd98 100644
--- a/lib/messages.js
+++ b/lib/messages.js
@@ -71,4 +71,4 @@ export const RESTORE_STASH_EXAMPLE = `  Any lost modifications can be restored f
     > git stash apply --index stash@{0}
 `
 
-export const CONFIG_STDIN_ERROR = 'Error: Could not read config from stdin.'
+export const CONFIG_STDIN_ERROR = chalk.redBright(`${error} Failed to read config from stdin.`)
diff --git a/lib/readStdin.js b/lib/readStdin.js
new file mode 100644
index 0000000..3ff5a6a
--- /dev/null
+++ b/lib/readStdin.js
@@ -0,0 +1,19 @@
+import { createInterface } from 'node:readline'
+
+/**
+ * Returns a promise resolving to the first line written to stdin after invoking.
+ * @warn will never resolve if called after writing to stdin
+ *
+ * @returns {Promise<string>}
+ */
+export const readStdin = () => {
+  const readline = createInterface({ input: process.stdin })
+
+  return new Promise((resolve) => {
+    readline.prompt()
+    readline.on('line', (line) => {
+      readline.close()
+      resolve(line)
+    })
+  })
+}
diff --git a/package-lock.json b/package-lock.json
index 5cb2f55..dcf35c9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -38,6 +38,7 @@
         "husky": "8.0.3",
         "jest": "29.6.2",
         "jest-snapshot-serializer-ansi": "2.1.0",
+        "mock-stdin": "1.0.0",
         "prettier": "3.0.1"
       },
       "engines": {
@@ -7300,6 +7301,12 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/mock-stdin": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/mock-stdin/-/mock-stdin-1.0.0.tgz",
+      "integrity": "sha512-tukRdb9Beu27t6dN+XztSRHq9J0B/CoAOySGzHfn8UTfmqipA5yNT/sDUEyYdAV3Hpka6Wx6kOMxuObdOex60Q==",
+      "dev": true
+    },
     "node_modules/ms": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
diff --git a/package.json b/package.json
index 45d8005..513d7b7 100644
--- a/package.json
+++ b/package.json
@@ -59,6 +59,7 @@
     "husky": "8.0.3",
     "jest": "29.6.2",
     "jest-snapshot-serializer-ansi": "2.1.0",
+    "mock-stdin": "1.0.0",
     "prettier": "3.0.1"
   },
   "keywords": [


Test patch:
diff --git a/test/e2e/__utils__/getLintStagedExecutor.js b/test/e2e/__utils__/getLintStagedExecutor.js
index d1e94af..cbbff62 100644
--- a/test/e2e/__utils__/getLintStagedExecutor.js
+++ b/test/e2e/__utils__/getLintStagedExecutor.js
@@ -2,7 +2,7 @@ import { resolve } from 'node:path'
 
 import { execaCommand } from 'execa'
 
-let lintStagedBin = resolve(__dirname, '../../../bin/lint-staged.js')
+const lintStagedBin = resolve(__dirname, '../../../bin/lint-staged.js')
 
 /**
  * @param {string} cwd
@@ -10,5 +10,5 @@ let lintStagedBin = resolve(__dirname, '../../../bin/lint-staged.js')
  */
 export const getLintStagedExecutor =
   (cwd) =>
-  async (params = '') =>
-    await execaCommand(`${lintStagedBin} --cwd=${cwd} ${params}`)
+  (params = '', options) =>
+    execaCommand(`${lintStagedBin} --cwd=${cwd} ${params}`, options)
diff --git a/test/e2e/stdin-config.test.js b/test/e2e/stdin-config.test.js
new file mode 100644
index 0000000..9c002fd
--- /dev/null
+++ b/test/e2e/stdin-config.test.js
@@ -0,0 +1,55 @@
+import '../integration/__mocks__/resolveConfig.js'
+
+import { jest } from '@jest/globals'
+
+import { withGitIntegration } from '../integration/__utils__/withGitIntegration.js'
+import * as fileFixtures from '../integration/__fixtures__/files.js'
+import * as configFixtures from '../integration/__fixtures__/configs.js'
+
+import { getLintStagedExecutor } from './__utils__/getLintStagedExecutor.js'
+
+jest.setTimeout(20000)
+jest.retryTimes(2)
+
+describe('lint-staged', () => {
+  test(
+    'reads config from stdin',
+    withGitIntegration(async ({ cwd, execGit, readFile, writeFile }) => {
+      const lintStaged = getLintStagedExecutor(cwd)
+
+      // Stage ugly file
+      await writeFile('test file.js', fileFixtures.uglyJS)
+      await execGit(['add', 'test file.js'])
+
+      // Run lint-staged with config from stdin
+      await lintStaged('-c -', {
+        input: JSON.stringify(configFixtures.prettierWrite),
+      })
+
+      // Nothing was wrong so file was prettified
+      expect(await readFile('test file.js')).toEqual(fileFixtures.prettyJS)
+    })
+  )
+
+  test(
+    'fails when stdin config is not valid',
+    withGitIntegration(async ({ cwd, execGit, readFile, writeFile }) => {
+      const lintStaged = getLintStagedExecutor(cwd)
+
+      // Stage ugly file
+      await writeFile('test file.js', fileFixtures.uglyJS)
+      await execGit(['add', 'test file.js'])
+
+      // Break JSON by removing } from the end
+      const brokenJSONConfig = JSON.stringify(configFixtures.prettierWrite).replace('"}', '"')
+
+      // Run lint-staged with broken config from stdin
+      await expect(lintStaged('-c -', { input: brokenJSONConfig })).rejects.toThrowError(
+        'Failed to read config from stdin'
+      )
+
+      // File was not edited
+      expect(await readFile('test file.js')).toEqual(fileFixtures.uglyJS)
+    })
+  )
+})
diff --git a/test/unit/readStdin.spec.js b/test/unit/readStdin.spec.js
new file mode 100644
index 0000000..2cea355
--- /dev/null
+++ b/test/unit/readStdin.spec.js
@@ -0,0 +1,16 @@
+import { stdin } from 'mock-stdin'
+
+import { readStdin } from '../../lib/readStdin.js'
+
+const mockStdin = stdin()
+
+describe('readStdin', () => {
+  it('should return stdin', async () => {
+    const stdinPromise = readStdin()
+
+    mockStdin.send('Hello, world!')
+    mockStdin.end()
+
+    expect(await stdinPromise).toEqual('Hello, world!')
+  })
+})