├── .npmrc ├── .gitattributes ├── .gitignore ├── .editorconfig ├── .github └── workflows │ └── main.yml ├── readme.md ├── package.json ├── license ├── cli.js └── test.js /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 24 14 | - 20 15 | steps: 16 | - uses: actions/checkout@v5 17 | - uses: actions/setup-node@v5 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm test 22 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # strip-bom-cli 2 | 3 | > Strip UTF-8 [byte order mark](http://en.wikipedia.org/wiki/Byte_order_mark#UTF-8) (BOM) 4 | 5 | From Wikipedia: 6 | 7 | > The Unicode Standard permits the BOM in UTF-8, but does not require nor recommend its use. Byte order has no meaning in UTF-8. 8 | 9 | ## Install 10 | 11 | ```sh 12 | npm install --global strip-bom-cli 13 | ``` 14 | 15 | ## Usage 16 | 17 | ``` 18 | $ strip-bom --help 19 | 20 | Usage 21 | $ strip-bom > 22 | $ strip-bom --in-place … 23 | $ cat | strip-bom > 24 | 25 | Options 26 | --in-place Modify the file in-place (be careful!) 27 | 28 | Examples 29 | $ strip-bom unicorn.txt > unicorn-without-bom.txt 30 | $ strip-bom --in-place unicorn.txt 31 | $ strip-bom --in-place *.txt 32 | ``` 33 | 34 | ## Related 35 | 36 | - [strip-bom](https://github.com/sindresorhus/strip-bom) - API for this package 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "strip-bom-cli", 3 | "version": "3.0.0", 4 | "description": "Strip UTF-8 byte order mark (BOM)", 5 | "license": "MIT", 6 | "repository": "sindresorhus/strip-bom-cli", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "bin": { 15 | "strip-bom": "./cli.js" 16 | }, 17 | "sideEffects": false, 18 | "engines": { 19 | "node": ">=20" 20 | }, 21 | "scripts": { 22 | "test": "xo && ava" 23 | }, 24 | "files": [ 25 | "cli.js" 26 | ], 27 | "keywords": [ 28 | "cli-app", 29 | "cli", 30 | "bom", 31 | "strip", 32 | "byte", 33 | "mark", 34 | "unicode", 35 | "utf8", 36 | "utf-8", 37 | "remove", 38 | "delete", 39 | "trim", 40 | "text", 41 | "stream", 42 | "streams" 43 | ], 44 | "dependencies": { 45 | "meow": "^14.0.0", 46 | "strip-bom-stream": "^5.0.0" 47 | }, 48 | "devDependencies": { 49 | "ava": "6.4.1", 50 | "execa": "^9.6.0", 51 | "xo": "^1.2.2" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import process from 'node:process'; 3 | import fs from 'node:fs'; 4 | import meow from 'meow'; 5 | import stripBomStream from 'strip-bom-stream'; 6 | 7 | const cli = meow(` 8 | Usage 9 | $ strip-bom > 10 | $ strip-bom --in-place … 11 | $ cat | strip-bom > 12 | 13 | Options 14 | --in-place Modify the file in-place (be careful!) 15 | 16 | Examples 17 | $ strip-bom unicorn.txt > unicorn-without-bom.txt 18 | $ strip-bom --in-place unicorn.txt 19 | $ strip-bom --in-place *.txt 20 | `, { 21 | importMeta: import.meta, 22 | flags: { 23 | inPlace: { 24 | type: 'boolean', 25 | default: false, 26 | }, 27 | }, 28 | }); 29 | 30 | function stripBomInPlace(filePath) { 31 | const fd = fs.openSync(filePath, 'r+'); 32 | 33 | try { 34 | const bomBuffer = new Uint8Array(3); 35 | const bytesRead = fs.readSync(fd, bomBuffer, 0, 3, 0); 36 | 37 | const hasBom = bytesRead >= 3 && bomBuffer[0] === 0xEF && bomBuffer[1] === 0xBB && bomBuffer[2] === 0xBF; 38 | 39 | if (!hasBom) { 40 | return false; 41 | } 42 | 43 | const stats = fs.fstatSync(fd); 44 | const fileSize = stats.size; 45 | 46 | const chunkSize = 64 * 1024; 47 | const buffer = new Uint8Array(chunkSize); 48 | let readPosition = 3; 49 | let writePosition = 0; 50 | 51 | while (readPosition < fileSize) { 52 | const toRead = Math.min(chunkSize, fileSize - readPosition); 53 | const bytesRead = fs.readSync(fd, buffer, 0, toRead, readPosition); 54 | fs.writeSync(fd, buffer, 0, bytesRead, writePosition); 55 | readPosition += bytesRead; 56 | writePosition += bytesRead; 57 | } 58 | 59 | fs.ftruncateSync(fd, fileSize - 3); 60 | return true; 61 | } finally { 62 | fs.closeSync(fd); 63 | } 64 | } 65 | 66 | const {input} = cli; 67 | 68 | if (input.length === 0 && process.stdin.isTTY) { 69 | console.error('Expected a filename'); 70 | process.exit(1); 71 | } 72 | 73 | if (cli.flags.inPlace) { 74 | if (input.length === 0) { 75 | console.error('Cannot use --in-place with stdin'); 76 | process.exit(1); 77 | } 78 | 79 | for (const file of input) { 80 | const hadBom = stripBomInPlace(file); 81 | console.error(`${file}: ${hadBom ? 'BOM removed' : 'no BOM found'}`); 82 | } 83 | } else if (input.length > 0) { 84 | if (input.length > 1) { 85 | console.error('Only one file can be specified when not using --in-place'); 86 | process.exit(1); 87 | } 88 | 89 | fs.createReadStream(input[0]).pipe(stripBomStream()).pipe(process.stdout); 90 | } else { 91 | process.stdin.pipe(stripBomStream()).pipe(process.stdout); 92 | } 93 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import {fileURLToPath} from 'node:url'; 4 | import test from 'ava'; 5 | import {execa} from 'execa'; 6 | 7 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 8 | const cliPath = path.join(__dirname, 'cli.js'); 9 | const BOM = '\uFEFF'; 10 | 11 | test('main', async t => { 12 | const {stdout} = await execa(cliPath, ['--version']); 13 | t.true(stdout.length > 0); 14 | }); 15 | 16 | test('strips BOM from file', async t => { 17 | const testFile = path.join(__dirname, 'test-strip.txt'); 18 | fs.writeFileSync(testFile, BOM + 'unicorn'); 19 | 20 | const {stdout} = await execa(cliPath, [testFile]); 21 | t.is(stdout, 'unicorn'); 22 | 23 | fs.unlinkSync(testFile); 24 | }); 25 | 26 | test('works with stdin', async t => { 27 | const {stdout} = await execa(cliPath, [], { 28 | input: BOM + 'unicorn', 29 | }); 30 | t.is(stdout, 'unicorn'); 31 | }); 32 | 33 | test('in-place flag strips BOM from file', async t => { 34 | const testFile = path.join(__dirname, 'test-in-place.txt'); 35 | fs.writeFileSync(testFile, BOM + 'unicorn'); 36 | 37 | const {stderr} = await execa(cliPath, ['--in-place', testFile]); 38 | t.regex(stderr, /BOM removed/); 39 | 40 | const content = fs.readFileSync(testFile, 'utf8'); 41 | t.is(content, 'unicorn'); 42 | 43 | fs.unlinkSync(testFile); 44 | }); 45 | 46 | test('in-place flag preserves file permissions', async t => { 47 | const testFile = path.join(__dirname, 'test-permissions.sh'); 48 | fs.writeFileSync(testFile, BOM + '#!/bin/bash\necho test'); 49 | fs.chmodSync(testFile, 0o755); 50 | 51 | const statsBefore = fs.statSync(testFile); 52 | const {stderr} = await execa(cliPath, ['--in-place', testFile]); 53 | t.regex(stderr, /BOM removed/); 54 | const statsAfter = fs.statSync(testFile); 55 | 56 | t.is(statsAfter.mode, statsBefore.mode); 57 | 58 | const content = fs.readFileSync(testFile, 'utf8'); 59 | t.is(content, '#!/bin/bash\necho test'); 60 | 61 | fs.unlinkSync(testFile); 62 | }); 63 | 64 | test('in-place flag does nothing when no BOM', async t => { 65 | const testFile = path.join(__dirname, 'test-no-bom.txt'); 66 | const originalContent = 'no BOM here'; 67 | fs.writeFileSync(testFile, originalContent); 68 | 69 | const {stderr} = await execa(cliPath, ['--in-place', testFile]); 70 | t.regex(stderr, /no BOM found/); 71 | 72 | const content = fs.readFileSync(testFile, 'utf8'); 73 | t.is(content, originalContent); 74 | 75 | fs.unlinkSync(testFile); 76 | }); 77 | 78 | test('in-place flag with large file', async t => { 79 | const testFile = path.join(__dirname, 'test-large.txt'); 80 | const largeContent = 'x'.repeat(100_000); 81 | fs.writeFileSync(testFile, BOM + largeContent); 82 | 83 | const {stderr} = await execa(cliPath, ['--in-place', testFile]); 84 | t.regex(stderr, /BOM removed/); 85 | 86 | const content = fs.readFileSync(testFile, 'utf8'); 87 | t.is(content, largeContent); 88 | 89 | fs.unlinkSync(testFile); 90 | }); 91 | 92 | test('fails when using --in-place with stdin', async t => { 93 | const {exitCode, stderr} = await t.throwsAsync(execa(cliPath, ['--in-place'], {input: 'test'})); 94 | t.is(exitCode, 1); 95 | t.regex(stderr, /Cannot use --in-place with stdin/); 96 | }); 97 | 98 | test('in-place flag with multiple files', async t => { 99 | const testFile1 = path.join(__dirname, 'test-multi-1.txt'); 100 | const testFile2 = path.join(__dirname, 'test-multi-2.txt'); 101 | const testFile3 = path.join(__dirname, 'test-multi-3.txt'); 102 | 103 | fs.writeFileSync(testFile1, BOM + 'file1'); 104 | fs.writeFileSync(testFile2, 'file2'); // No BOM 105 | fs.writeFileSync(testFile3, BOM + 'file3'); 106 | 107 | const {stderr} = await execa(cliPath, ['--in-place', testFile1, testFile2, testFile3]); 108 | t.regex(stderr, new RegExp(`${testFile1}: BOM removed`)); 109 | t.regex(stderr, new RegExp(`${testFile2}: no BOM found`)); 110 | t.regex(stderr, new RegExp(`${testFile3}: BOM removed`)); 111 | 112 | t.is(fs.readFileSync(testFile1, 'utf8'), 'file1'); 113 | t.is(fs.readFileSync(testFile2, 'utf8'), 'file2'); 114 | t.is(fs.readFileSync(testFile3, 'utf8'), 'file3'); 115 | 116 | fs.unlinkSync(testFile1); 117 | fs.unlinkSync(testFile2); 118 | fs.unlinkSync(testFile3); 119 | }); 120 | 121 | test('fails when multiple files provided without --in-place', async t => { 122 | const {exitCode, stderr} = await t.throwsAsync(execa(cliPath, ['file1.txt', 'file2.txt'])); 123 | t.is(exitCode, 1); 124 | t.regex(stderr, /Only one file can be specified when not using --in-place/); 125 | }); 126 | 127 | test('handles non-existent file gracefully', async t => { 128 | const nonExistentFile = path.join(__dirname, 'non-existent.txt'); 129 | await t.throwsAsync(execa(cliPath, ['--in-place', nonExistentFile]), { 130 | message: /ENOENT/, 131 | }); 132 | }); 133 | 134 | test('handles file with only BOM', async t => { 135 | const testFile = path.join(__dirname, 'test-only-bom.txt'); 136 | fs.writeFileSync(testFile, BOM); 137 | 138 | const {stderr} = await execa(cliPath, ['--in-place', testFile]); 139 | t.regex(stderr, /BOM removed/); 140 | 141 | const content = fs.readFileSync(testFile, 'utf8'); 142 | t.is(content, ''); 143 | t.is(fs.statSync(testFile).size, 0); 144 | 145 | fs.unlinkSync(testFile); 146 | }); 147 | 148 | test('preserves binary data integrity after BOM', async t => { 149 | const testFile = path.join(__dirname, 'test-binary.bin'); 150 | const bom = new Uint8Array([0xEF, 0xBB, 0xBF]); 151 | const text = new TextEncoder().encode('Text\n'); 152 | const binaryBytes = new Uint8Array([0x00, 0xFF, 0x80, 0x7F]); 153 | const moreText = new TextEncoder().encode('\nMore text'); 154 | 155 | const binaryData = new Uint8Array(bom.length + text.length + binaryBytes.length + moreText.length); 156 | binaryData.set(bom, 0); 157 | binaryData.set(text, bom.length); 158 | binaryData.set(binaryBytes, bom.length + text.length); 159 | binaryData.set(moreText, bom.length + text.length + binaryBytes.length); 160 | 161 | fs.writeFileSync(testFile, binaryData); 162 | 163 | const {stderr} = await execa(cliPath, ['--in-place', testFile]); 164 | t.regex(stderr, /BOM removed/); 165 | 166 | const result = new Uint8Array(fs.readFileSync(testFile)); 167 | const expected = binaryData.slice(3); 168 | t.true(result.every((byte, i) => byte === expected[i])); 169 | t.is(result.length, expected.length); 170 | 171 | fs.unlinkSync(testFile); 172 | }); 173 | 174 | test('handles chunk boundaries correctly', async t => { 175 | const testFile = path.join(__dirname, 'test-boundary.bin'); 176 | const size = 64 * 1024; // Exactly one chunk 177 | const testData = new Uint8Array(size); 178 | for (let i = 0; i < size; i++) { 179 | testData[i] = i % 256; 180 | } 181 | 182 | const withBom = new Uint8Array(3 + size); 183 | withBom.set([0xEF, 0xBB, 0xBF], 0); 184 | withBom.set(testData, 3); 185 | fs.writeFileSync(testFile, withBom); 186 | 187 | const {stderr} = await execa(cliPath, ['--in-place', testFile]); 188 | t.regex(stderr, /BOM removed/); 189 | 190 | const result = new Uint8Array(fs.readFileSync(testFile)); 191 | t.is(result.length, size); 192 | t.true(result.every((byte, i) => byte === testData[i])); 193 | 194 | fs.unlinkSync(testFile); 195 | }); 196 | --------------------------------------------------------------------------------