├── .prettierignore ├── test ├── fixtures │ ├── example │ │ ├── one.txt │ │ ├── two.txt │ │ ├── alpha.text │ │ ├── bravo.text │ │ ├── delta.text │ │ ├── charlie │ │ │ ├── echo.text │ │ │ ├── delta.text │ │ │ └── ignore │ │ ├── three │ │ │ ├── five.txt │ │ │ └── four.txt │ │ ├── tree.json │ │ ├── cli.js │ │ ├── SHORT_FLAG │ │ ├── LONG_FLAG │ │ └── HELP │ ├── plugins │ │ ├── one.txt │ │ ├── package.json │ │ ├── plugin.js │ │ └── cli.js │ ├── settings │ │ ├── one.txt │ │ └── cli.js │ ├── uncaught-errors │ │ ├── one.txt │ │ ├── package.json │ │ ├── plugin.js │ │ └── cli.js │ ├── config.js │ └── processor.js └── index.js ├── .npmrc ├── .gitignore ├── index.js ├── .editorconfig ├── .github └── workflows │ ├── bb.yml │ └── main.yml ├── tsconfig.json ├── license ├── package.json ├── lib ├── schema.js ├── index.js └── parse-argv.js └── readme.md /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | *.md 3 | -------------------------------------------------------------------------------- /test/fixtures/example/one.txt: -------------------------------------------------------------------------------- 1 | one 2 | -------------------------------------------------------------------------------- /test/fixtures/example/two.txt: -------------------------------------------------------------------------------- 1 | two 2 | -------------------------------------------------------------------------------- /test/fixtures/plugins/one.txt: -------------------------------------------------------------------------------- 1 | one 2 | -------------------------------------------------------------------------------- /test/fixtures/settings/one.txt: -------------------------------------------------------------------------------- 1 | one 2 | -------------------------------------------------------------------------------- /test/fixtures/example/alpha.text: -------------------------------------------------------------------------------- 1 | alpha 2 | -------------------------------------------------------------------------------- /test/fixtures/example/bravo.text: -------------------------------------------------------------------------------- 1 | bravo 2 | -------------------------------------------------------------------------------- /test/fixtures/example/delta.text: -------------------------------------------------------------------------------- 1 | delta 2 | -------------------------------------------------------------------------------- /test/fixtures/example/charlie/echo.text: -------------------------------------------------------------------------------- 1 | echo 2 | -------------------------------------------------------------------------------- /test/fixtures/example/three/five.txt: -------------------------------------------------------------------------------- 1 | five 2 | -------------------------------------------------------------------------------- /test/fixtures/example/three/four.txt: -------------------------------------------------------------------------------- 1 | four 2 | -------------------------------------------------------------------------------- /test/fixtures/uncaught-errors/one.txt: -------------------------------------------------------------------------------- 1 | one 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-scripts=true 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /test/fixtures/example/charlie/delta.text: -------------------------------------------------------------------------------- 1 | delta 2 | -------------------------------------------------------------------------------- /test/fixtures/example/charlie/ignore: -------------------------------------------------------------------------------- 1 | delta.text 2 | -------------------------------------------------------------------------------- /test/fixtures/plugins/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/uncaught-errors/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/example/tree.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "text", 3 | "value": "hi!" 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.d.ts 2 | *.log 3 | *.map 4 | *.tsbuildinfo 5 | .DS_Store 6 | coverage/ 7 | node_modules/ 8 | yarn.lock 9 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('./lib/parse-argv.js').Options} Options 3 | */ 4 | 5 | export {args} from './lib/index.js' 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /test/fixtures/plugins/plugin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {Plugin} from 'unified' 3 | */ 4 | 5 | /** @type {Plugin<[unknown?]>} */ 6 | export default function plugin(options) { 7 | console.log(JSON.stringify(options)) 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/uncaught-errors/plugin.js: -------------------------------------------------------------------------------- 1 | export default function plugin() {} 2 | 3 | setTimeout(thrower, 1000) 4 | 5 | function thrower() { 6 | // eslint-disable-next-line no-throw-literal -- intentional. 7 | throw 'foo' 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/plugins/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import {args} from '../../../index.js' 3 | import {config} from '../config.js' 4 | import {processor} from '../processor.js' 5 | 6 | args({...config, cwd: new URL('.', import.meta.url), processor}) 7 | -------------------------------------------------------------------------------- /test/fixtures/config.js: -------------------------------------------------------------------------------- 1 | export const config = { 2 | description: 'Foo processor', 3 | extensions: ['txt'], 4 | ignoreName: '.fooignore', 5 | name: 'foo', 6 | packageField: 'fooConfig', 7 | pluginPrefix: 'foo', 8 | rcName: '.foorc', 9 | version: '0.0.0' 10 | } 11 | -------------------------------------------------------------------------------- /test/fixtures/uncaught-errors/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import {args} from '../../../index.js' 3 | import {config} from '../config.js' 4 | import {processor} from '../processor.js' 5 | 6 | args({ 7 | ...config, 8 | cwd: new URL('.', import.meta.url), 9 | processor 10 | }) 11 | -------------------------------------------------------------------------------- /test/fixtures/settings/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import {args} from '../../../index.js' 3 | import {config} from '../config.js' 4 | import {processor} from '../processor.js' 5 | 6 | args({ 7 | ...config, 8 | cwd: new URL('.', import.meta.url), 9 | processor: processor().use(function () { 10 | console.log(JSON.stringify(this.data('settings'))) 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /test/fixtures/example/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import path from 'node:path' 3 | import {args} from '../../../index.js' 4 | import {config} from '../config.js' 5 | import {processor} from '../processor.js' 6 | 7 | // Note: the `path` usage is intentional: we want to make sure file paths in `string` form work too. 8 | args({...config, cwd: path.join('test', 'fixtures', 'example'), processor}) 9 | -------------------------------------------------------------------------------- /.github/workflows/bb.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | main: 3 | runs-on: ubuntu-latest 4 | steps: 5 | - uses: unifiedjs/beep-boop-beta@main 6 | with: 7 | repo-token: ${{secrets.GITHUB_TOKEN}} 8 | name: bb 9 | on: 10 | issues: 11 | types: [closed, edited, labeled, opened, reopened, unlabeled] 12 | pull_request_target: 13 | types: [closed, edited, labeled, opened, reopened, unlabeled] 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": true, 4 | "customConditions": ["development"], 5 | "declarationMap": true, 6 | "declaration": true, 7 | "emitDeclarationOnly": true, 8 | "exactOptionalPropertyTypes": true, 9 | "lib": ["es2022"], 10 | "module": "node16", 11 | "strict": true, 12 | "target": "es2022" 13 | }, 14 | "exclude": ["coverage/", "node_modules/"], 15 | "include": ["**/*.js"] 16 | } 17 | -------------------------------------------------------------------------------- /test/fixtures/example/SHORT_FLAG: -------------------------------------------------------------------------------- 1 | Error: Unknown short option `-n`, expected: 2 | -e --ext specify extensions 3 | -f --frail exit with 1 on warnings 4 | -h --help output usage information 5 | -i --ignore-path specify ignore file 6 | -o --output [path] specify output location 7 | -q --quiet output only warnings and errors 8 | -r --rc-path specify configuration file 9 | -s --setting specify settings 10 | -S --silent output only errors 11 | -t --tree specify input and output as syntax tree 12 | -u --use use plugins 13 | -v --version output version number 14 | -w --watch watch for changes and reprocess 15 | -------------------------------------------------------------------------------- /test/fixtures/processor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {Compiler, Parser, Plugin} from 'unified' 3 | * @import {Literal, Node} from 'unist' 4 | */ 5 | 6 | import {unified} from 'unified' 7 | 8 | export const processor = unified() 9 | .use( 10 | /** @type {Plugin<[], string, Node>} */ 11 | // @ts-expect-error: TS is wrong about `this`. 12 | function () { 13 | /** @type {Parser} */ 14 | this.parser = function (value) { 15 | /** @type {Literal} */ 16 | const node = {type: 'text', value} 17 | return node 18 | } 19 | } 20 | ) 21 | .use( 22 | /** @type {Plugin<[], Node, string>} */ 23 | // @ts-expect-error: TS is wrong about `this`. 24 | function () { 25 | /** @type {Compiler} */ 26 | this.compiler = function (tree) { 27 | const node = /** @type {Literal} */ (tree) 28 | return String(node.value) 29 | } 30 | } 31 | ) 32 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | unix: 3 | name: '${{matrix.node}} on ${{matrix.os}}' 4 | runs-on: ${{matrix.os}} 5 | steps: 6 | - uses: actions/checkout@v4 7 | - uses: actions/setup-node@v4 8 | with: 9 | node-version: ${{matrix.node}} 10 | - run: npm install 11 | - run: npm test 12 | - uses: codecov/codecov-action@v5 13 | strategy: 14 | matrix: 15 | os: 16 | - ubuntu-latest 17 | node: 18 | - lts/hydrogen 19 | - node 20 | # Just run quick tests on windows. 21 | windows: 22 | name: '${{matrix.node}} on ${{matrix.os}}' 23 | runs-on: ${{matrix.os}} 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: actions/setup-node@v4 27 | with: 28 | node-version: ${{matrix.node}} 29 | - run: npm install 30 | - run: npm run test-api 31 | strategy: 32 | matrix: 33 | os: 34 | - windows-latest 35 | node: 36 | - lts/hydrogen 37 | - node 38 | name: main 39 | on: 40 | - pull_request 41 | - push 42 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) Titus Wormer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/fixtures/example/LONG_FLAG: -------------------------------------------------------------------------------- 1 | Error: Unknown option `--no`, expected: 2 | --[no-]color specify color in report (on by default) 3 | --[no-]config search for configuration files (on by default) 4 | -e --ext specify extensions 5 | --file-path specify path to process as 6 | -f --frail exit with 1 on warnings 7 | -h --help output usage information 8 | --[no-]ignore search for ignore files (on by default) 9 | -i --ignore-path specify ignore file 10 | --ignore-path-resolve-from cwd|dir resolve patterns in `ignore-path` from its directory or cwd 11 | --ignore-pattern specify ignore patterns 12 | --inspect output formatted syntax tree 13 | -o --output [path] specify output location 14 | -q --quiet output only warnings and errors 15 | -r --rc-path specify configuration file 16 | --report specify reporter 17 | -s --setting specify settings 18 | -S --silent output only errors 19 | --silently-ignore do not fail when given ignored files 20 | --[no-]stdout specify writing to stdout (on by default) 21 | -t --tree specify input and output as syntax tree 22 | --tree-in specify input as syntax tree 23 | --tree-out output syntax tree 24 | -u --use use plugins 25 | --verbose report extra info for messages 26 | -v --version output version number 27 | -w --watch watch for changes and reprocess 28 | -------------------------------------------------------------------------------- /test/fixtures/example/HELP: -------------------------------------------------------------------------------- 1 | Usage: foo [options] [path | glob ...] 2 | 3 | Foo processor 4 | 5 | Options: 6 | 7 | --[no-]color specify color in report (on by default) 8 | --[no-]config search for configuration files (on by default) 9 | -e --ext specify extensions 10 | --file-path specify path to process as 11 | -f --frail exit with 1 on warnings 12 | -h --help output usage information 13 | --[no-]ignore search for ignore files (on by default) 14 | -i --ignore-path specify ignore file 15 | --ignore-path-resolve-from cwd|dir resolve patterns in `ignore-path` from its directory or cwd 16 | --ignore-pattern specify ignore patterns 17 | --inspect output formatted syntax tree 18 | -o --output [path] specify output location 19 | -q --quiet output only warnings and errors 20 | -r --rc-path specify configuration file 21 | --report specify reporter 22 | -s --setting specify settings 23 | -S --silent output only errors 24 | --silently-ignore do not fail when given ignored files 25 | --[no-]stdout specify writing to stdout (on by default) 26 | -t --tree specify input and output as syntax tree 27 | --tree-in specify input as syntax tree 28 | --tree-out output syntax tree 29 | -u --use use plugins 30 | --verbose report extra info for messages 31 | -v --version output version number 32 | -w --watch watch for changes and reprocess 33 | 34 | Examples: 35 | 36 | # Process `input.txt` 37 | $ foo input.txt -o output.txt 38 | 39 | # Pipe 40 | $ foo < input.txt > output.txt 41 | 42 | # Rewrite all applicable files 43 | $ foo . -o 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Titus Wormer (https://wooorm.com)", 3 | "bugs": "https://github.com/unifiedjs/unified-args/issues", 4 | "contributors": [ 5 | "Titus Wormer (https://wooorm.com)", 6 | "Christian Murphy } */ 24 | export const schema = [ 25 | { 26 | default: true, 27 | description: 'specify color in report', 28 | long: 'color', 29 | type: 'boolean' 30 | }, 31 | { 32 | default: true, 33 | description: 'search for configuration files', 34 | long: 'config', 35 | type: 'boolean' 36 | }, 37 | { 38 | common: true, 39 | description: 'specify extensions', 40 | long: 'ext', 41 | short: 'e', 42 | type: 'string', 43 | value: '' 44 | }, 45 | { 46 | description: 'specify path to process as', 47 | long: 'file-path', 48 | type: 'string', 49 | value: '' 50 | }, 51 | { 52 | common: true, 53 | default: false, 54 | description: 'exit with 1 on warnings', 55 | long: 'frail', 56 | short: 'f', 57 | type: 'boolean' 58 | }, 59 | { 60 | common: true, 61 | default: false, 62 | description: 'output usage information', 63 | long: 'help', 64 | short: 'h', 65 | type: 'boolean' 66 | }, 67 | { 68 | default: true, 69 | description: 'search for ignore files', 70 | long: 'ignore', 71 | type: 'boolean' 72 | }, 73 | { 74 | description: 'specify ignore file', 75 | long: 'ignore-path', 76 | short: 'i', 77 | type: 'string', 78 | value: '' 79 | }, 80 | { 81 | default: 'dir', 82 | description: 'resolve patterns in `ignore-path` from its directory or cwd', 83 | long: 'ignore-path-resolve-from', 84 | type: 'string', 85 | value: 'cwd|dir' 86 | }, 87 | { 88 | description: 'specify ignore patterns', 89 | long: 'ignore-pattern', 90 | type: 'string', 91 | value: '' 92 | }, 93 | { 94 | default: false, 95 | description: 'output formatted syntax tree', 96 | long: 'inspect', 97 | type: 'boolean' 98 | }, 99 | { 100 | common: true, 101 | description: 'specify output location', 102 | long: 'output', 103 | short: 'o', 104 | value: '[path]' 105 | }, 106 | { 107 | common: true, 108 | default: false, 109 | description: 'output only warnings and errors', 110 | long: 'quiet', 111 | short: 'q', 112 | type: 'boolean' 113 | }, 114 | { 115 | description: 'specify configuration file', 116 | long: 'rc-path', 117 | short: 'r', 118 | type: 'string', 119 | value: '' 120 | }, 121 | { 122 | description: 'specify reporter', 123 | long: 'report', 124 | type: 'string', 125 | value: '' 126 | }, 127 | { 128 | description: 'specify settings', 129 | long: 'setting', 130 | short: 's', 131 | type: 'string', 132 | value: '' 133 | }, 134 | { 135 | default: false, 136 | description: 'output only errors', 137 | long: 'silent', 138 | short: 'S', 139 | type: 'boolean' 140 | }, 141 | { 142 | default: false, 143 | description: 'do not fail when given ignored files', 144 | long: 'silently-ignore', 145 | type: 'boolean' 146 | }, 147 | { 148 | description: 'specify writing to stdout', 149 | long: 'stdout', 150 | truelike: true, 151 | type: 'boolean' 152 | }, 153 | { 154 | default: false, 155 | description: 'specify input and output as syntax tree', 156 | long: 'tree', 157 | short: 't', 158 | type: 'boolean' 159 | }, 160 | { 161 | description: 'specify input as syntax tree', 162 | long: 'tree-in', 163 | type: 'boolean' 164 | }, 165 | { 166 | description: 'output syntax tree', 167 | long: 'tree-out', 168 | type: 'boolean' 169 | }, 170 | { 171 | common: true, 172 | description: 'use plugins', 173 | long: 'use', 174 | short: 'u', 175 | type: 'string', 176 | value: '' 177 | }, 178 | { 179 | common: true, 180 | default: false, 181 | description: 'report extra info for messages', 182 | long: 'verbose', 183 | type: 'boolean' 184 | }, 185 | { 186 | default: false, 187 | description: 'output version number', 188 | long: 'version', 189 | short: 'v', 190 | type: 'boolean' 191 | }, 192 | { 193 | common: true, 194 | default: false, 195 | description: 'watch for changes and reprocess', 196 | long: 'watch', 197 | short: 'w', 198 | type: 'boolean' 199 | } 200 | ] 201 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {Options} from 'unified-args' 3 | * @import {Callback as EngineCallback, Context as EngineContext} from 'unified-engine' 4 | * @import {State} from './parse-argv.js' 5 | */ 6 | 7 | import process from 'node:process' 8 | import stream from 'node:stream' 9 | import {fileURLToPath} from 'node:url' 10 | import chalk from 'chalk' 11 | import chokidar from 'chokidar' 12 | import {engine} from 'unified-engine' 13 | import {parseArgv} from './parse-argv.js' 14 | 15 | // Fake TTY stream. 16 | const ttyStream = new stream.Readable() 17 | // @ts-expect-error: TS doesn’t understand but that’s how Node streams work. 18 | ttyStream.isTTY = true 19 | 20 | // Handle uncaught errors, such as from unexpected async behaviour. 21 | process.on('uncaughtException', fail) 22 | 23 | /** 24 | * Start the CLI. 25 | * 26 | * > 👉 **Note**: this takes over the entire process. 27 | * > It parses `process.argv`, exits when its done, etc. 28 | * 29 | * @param {Options} options 30 | * Configuration (required). 31 | * @returns {undefined} 32 | * Nothing. 33 | */ 34 | // eslint-disable-next-line unicorn/prevent-abbreviations 35 | export function args(options) { 36 | /** @type {State} */ 37 | let state 38 | /** @type {chokidar.FSWatcher | undefined} */ 39 | let watcher 40 | /** @type {URL | boolean | string | undefined} */ 41 | let output 42 | 43 | try { 44 | state = parseArgv(process.argv.slice(2), options) 45 | } catch (error) { 46 | const exception = /** @type {Error} */ (error) 47 | return fail(exception) 48 | } 49 | 50 | if (state.args.help) { 51 | process.stdout.write( 52 | [ 53 | 'Usage: ' + options.name + ' [options] [path | glob ...]', 54 | '', 55 | ' ' + options.description, 56 | '', 57 | 'Options:', 58 | '', 59 | state.args.helpMessage, 60 | '' 61 | ].join('\n'), 62 | noop 63 | ) 64 | 65 | return 66 | } 67 | 68 | if (state.args.version) { 69 | process.stdout.write(options.version + '\n', noop) 70 | return 71 | } 72 | 73 | // Modify `state` for watching. 74 | if (state.args.watch) { 75 | output = state.engine.output 76 | 77 | // Do not read from stdin(4). 78 | state.engine.streamIn = ttyStream 79 | 80 | // Do not write to stdout(4). 81 | state.engine.out = false 82 | 83 | process.stderr.write( 84 | chalk.bold('Watching...') + ' (press CTRL+C to exit)\n', 85 | noop 86 | ) 87 | 88 | // Prevent infinite loop if set to regeneration. 89 | if (output === true) { 90 | state.engine.output = false 91 | process.stderr.write( 92 | chalk.yellow('Note') + ': Ignoring `--output` until exit.\n', 93 | noop 94 | ) 95 | } 96 | } 97 | 98 | // Initial run. 99 | engine(state.engine, done) 100 | 101 | /** 102 | * Handle complete run. 103 | * 104 | * @type {EngineCallback} 105 | */ 106 | function done(error, code, context) { 107 | if (error) { 108 | clean() 109 | fail(error) 110 | } else { 111 | if (typeof code === 'number') process.exitCode = code 112 | 113 | if (state.args.watch && !watcher && context) { 114 | subscribe(context) 115 | } 116 | } 117 | } 118 | 119 | // Clean the watcher. 120 | function clean() { 121 | if (watcher) { 122 | process.removeListener('SIGINT', onsigint) 123 | watcher.close() 124 | watcher = undefined 125 | } 126 | } 127 | 128 | /** 129 | * Subscribe a chokidar watcher to all processed files. 130 | * 131 | * @param {EngineContext} context 132 | * Context. 133 | * @returns {undefined} 134 | * Nothing. 135 | */ 136 | function subscribe(context) { 137 | /** @type {Array} */ 138 | const urls = context.fileSet.origins 139 | /* c8 ignore next 4 - this works, but it’s only used in `watch`, where we have one config for */ 140 | const cwd = 141 | typeof state.engine.cwd === 'object' 142 | ? fileURLToPath(state.engine.cwd) 143 | : state.engine.cwd 144 | 145 | watcher = chokidar 146 | .watch( 147 | urls, 148 | // @ts-expect-error: chokidar types are wrong w/ 149 | // `exactOptionalPropertyTypes`, 150 | // `cwd` can be `undefined`. 151 | {cwd, ignoreInitial: true} 152 | ) 153 | .on('error', done) 154 | .on('change', function (filePath) { 155 | state.engine.files = [filePath] 156 | engine(state.engine, done) 157 | }) 158 | 159 | process.on('SIGINT', onsigint) 160 | } 161 | 162 | /** 163 | * Handle a SIGINT. 164 | */ 165 | function onsigint() { 166 | // Hide the `^C` in terminal. 167 | process.stderr.write('\n', noop) 168 | 169 | clean() 170 | 171 | // Do another process if `output` specified regeneration. 172 | if (output === true) { 173 | state.engine.output = output 174 | state.args.watch = false 175 | engine(state.engine, done) 176 | } 177 | } 178 | } 179 | 180 | /** 181 | * Print an error to `stderr`, optionally with stack. 182 | * 183 | * @param {Error} error 184 | * Error to print. 185 | * @returns {undefined} 186 | * Nothing. 187 | */ 188 | function fail(error) { 189 | process.exitCode = 1 190 | process.stderr.write(String(error.stack || error).trimEnd() + '\n', noop) 191 | } 192 | 193 | /** 194 | * Do nothing. 195 | * 196 | * @returns {undefined} 197 | * Nothing. 198 | */ 199 | function noop() {} 200 | -------------------------------------------------------------------------------- /lib/parse-argv.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {Options as EngineOptions, Preset} from 'unified-engine' 3 | * @import {Field} from './schema.js' 4 | */ 5 | 6 | /** 7 | * @typedef {Exclude | undefined>} PluggableMap 8 | */ 9 | 10 | /** 11 | * @typedef ArgsFields 12 | * Configuration specific to `unified-args`. 13 | * @property {string} name 14 | * Name of executable. 15 | * @property {string} description 16 | * Description of executable. 17 | * @property {string} version 18 | * Version (semver) of executable. 19 | * 20 | * @typedef ArgsOptions 21 | * Configuration for `unified-args`. 22 | * @property {boolean} help 23 | * Whether to show help info. 24 | * @property {string} helpMessage 25 | * Help message. 26 | * @property {boolean} version 27 | * Whether to show version info. 28 | * @property {boolean} watch 29 | * Whether to run in watch mode. 30 | * 31 | * @typedef {{[Key in 'cwd']?: EngineOptions[Key]}} EngineFieldsOptional 32 | * Optional configuration for `unified-engine` that can be passed. 33 | * 34 | * @typedef {( 35 | * { 36 | * [Key in 'extensions' | 'ignoreName' | 'packageField' | 'pluginPrefix' | 'processor' | 'rcName']: 37 | * Exclude 38 | * } 39 | * )} EngineFieldsRequired 40 | * 41 | * Configuration for `unified-engine` that must be passed. 42 | * 43 | * @typedef {ArgsFields & EngineFieldsOptional & EngineFieldsRequired} Options 44 | * Configuration. 45 | * 46 | * @typedef State 47 | * Parsed options for `args` itself and for the engine. 48 | * @property {EngineOptions} engine 49 | * Configuration for `unified-engine`. 50 | * @property {ArgsOptions} args 51 | * Configuration for `unified-args`. 52 | */ 53 | 54 | import assert from 'node:assert/strict' 55 | import {parse as commaParse} from 'comma-separated-tokens' 56 | import chalk from 'chalk' 57 | import json5 from 'json5' 58 | import minimist from 'minimist' 59 | import stripAnsi from 'strip-ansi' 60 | import table from 'text-table' 61 | import {schema} from './schema.js' 62 | 63 | const own = {}.hasOwnProperty 64 | 65 | /** 66 | * Schema for `minimist`. 67 | * 68 | * @satisfies {minimist.Opts} 69 | */ 70 | const minischema = { 71 | /** @type {Record} */ 72 | alias: {}, 73 | /** @type {Array} */ 74 | boolean: [], 75 | /** @type {Record} */ 76 | default: {}, 77 | /** @type {Array} */ 78 | string: [], 79 | unknown: handleUnknownArgument 80 | } 81 | 82 | let index = -1 83 | while (++index < schema.length) { 84 | const field = schema[index] 85 | 86 | // Has to be `null`, otherwise it fails. 87 | minischema.default[field.long] = 88 | field.default === undefined ? null : field.default 89 | 90 | if (field.type && field.type in minischema) { 91 | minischema[field.type].push(field.long) 92 | } 93 | 94 | if (field.short) { 95 | minischema.alias[field.short] = field.long 96 | } 97 | } 98 | 99 | /** 100 | * Parse CLI options. 101 | * 102 | * @param {Array} flags 103 | * Flags. 104 | * @param {Options} options 105 | * Configuration (required). 106 | * @returns {State} 107 | * Parsed options. 108 | */ 109 | // eslint-disable-next-line complexity 110 | export function parseArgv(flags, options) { 111 | /** @type {Record | string | boolean | undefined>} */ 112 | const config = minimist(flags, minischema) 113 | let index = -1 114 | 115 | // Fix defaults: minimist only understand `null`, not `undefined`, so we had to use `null`. 116 | // But we want `undefined`, so clean it here. 117 | /** @type {string} */ 118 | let key 119 | 120 | for (key in config) { 121 | if (config[key] === null) { 122 | config[key] = undefined 123 | } 124 | } 125 | 126 | // Crash on passed but missing string values. 127 | while (++index < schema.length) { 128 | const field = schema[index] 129 | if (field.type === 'string' && config[field.long] === '') { 130 | throw new Error('Missing value: ' + inspect(field).join(' ').trimStart()) 131 | } 132 | } 133 | 134 | // Make sure we parsed everything correctly. 135 | // Minimist guarantees that `''` is an array of strings. 136 | assert(Array.isArray(config._)) 137 | // Minimist guarantees that our booleans are never strings. 138 | // Most have defaults, so they’re not `undefined`. 139 | assert(typeof config.color === 'boolean') 140 | assert(typeof config.config === 'boolean') 141 | assert(typeof config.frail === 'boolean') 142 | assert(typeof config.help === 'boolean') 143 | assert(typeof config.ignore === 'boolean') 144 | assert(typeof config.inspect === 'boolean') 145 | assert(typeof config.quiet === 'boolean') 146 | assert(typeof config.silent === 'boolean') 147 | assert(typeof config['silently-ignore'] === 'boolean') 148 | assert(typeof config.tree === 'boolean') 149 | assert(typeof config.verbose === 'boolean') 150 | assert(typeof config.version === 'boolean') 151 | assert(typeof config.watch === 'boolean') 152 | assert(config.stdout === undefined || typeof config.stdout === 'boolean') 153 | assert( 154 | config['tree-in'] === undefined || typeof config['tree-in'] === 'boolean' 155 | ) 156 | assert( 157 | config['tree-out'] === undefined || typeof config['tree-out'] === 'boolean' 158 | ) 159 | 160 | // The rest are strings, never booleans, but with minimist they could be 161 | // arrays. 162 | // `ignore-path-resolve-from` is an enum. 163 | const ignorePathResolveFrom = undefinedIfBoolean( 164 | lastIfArray(config['ignore-path-resolve-from']) 165 | ) 166 | 167 | if ( 168 | ignorePathResolveFrom !== undefined && 169 | ignorePathResolveFrom !== 'cwd' && 170 | ignorePathResolveFrom !== 'dir' 171 | ) { 172 | throw new Error( 173 | "Expected `'cwd'` or `'dir'` for `ignore-path-resolve-from`, not: `" + 174 | ignorePathResolveFrom + 175 | '`' 176 | ) 177 | } 178 | 179 | const filePath = lastIfArray(undefinedIfBoolean(config['file-path'])) 180 | const ignorePath = lastIfArray(undefinedIfBoolean(config['ignore-path'])) 181 | const rcPath = lastIfArray(undefinedIfBoolean(config['rc-path'])) 182 | const output = lastIfArray(config.output) 183 | 184 | const extnames = 185 | parseIfString(joinIfArray(undefinedIfBoolean(config.ext))) || [] 186 | const ignorePattern = 187 | parseIfString(joinIfArray(undefinedIfBoolean(config['ignore-pattern']))) || 188 | [] 189 | 190 | const setting = toArray(undefinedIfBoolean(config.setting)) || [] 191 | /** @type {Record} */ 192 | const settings = {} 193 | index = -1 194 | while (++index < setting.length) { 195 | parseConfig(setting[index], settings) 196 | } 197 | 198 | const report = lastIfArray(undefinedIfBoolean(config.report)) 199 | /** @type {string | undefined} */ 200 | let reporter 201 | /** @type {Record} */ 202 | const reporterOptions = {} 203 | 204 | if (report) { 205 | const [key, value] = splitOptions(report) 206 | reporter = key 207 | if (value) parseConfig(value, reporterOptions) 208 | } 209 | 210 | const use = toArray(undefinedIfBoolean(config.use)) || [] 211 | /** @type {PluggableMap} */ 212 | const plugins = {} 213 | index = -1 214 | while (++index < use.length) { 215 | /** @type {Record} */ 216 | const options = {} 217 | const [key, value] = splitOptions(use[index]) 218 | if (value) parseConfig(value, options) 219 | plugins[key] = options 220 | } 221 | 222 | return { 223 | args: { 224 | help: config.help, 225 | helpMessage: generateHelpMessage(options), 226 | version: config.version, 227 | watch: config.watch 228 | }, 229 | engine: { 230 | color: config.color, 231 | cwd: options.cwd, 232 | detectConfig: config.config, 233 | detectIgnore: config.ignore, 234 | extensions: extnames.length === 0 ? options.extensions : extnames, 235 | filePath, 236 | files: config._, 237 | frail: config.frail, 238 | ignoreName: options.ignoreName, 239 | ignorePath, 240 | ignorePathResolveFrom, 241 | ignorePatterns: ignorePattern, 242 | inspect: config.inspect, 243 | output, 244 | out: config.stdout, 245 | packageField: options.packageField, 246 | pluginPrefix: options.pluginPrefix, 247 | plugins, 248 | processor: options.processor, 249 | quiet: config.quiet, 250 | rcName: options.rcName, 251 | rcPath, 252 | reporter, 253 | reporterOptions, 254 | settings, 255 | silent: config.silent, 256 | silentlyIgnore: config['silently-ignore'], 257 | tree: config.tree, 258 | treeIn: config['tree-in'], 259 | treeOut: config['tree-out'], 260 | verbose: config.verbose 261 | } 262 | } 263 | } 264 | 265 | /** 266 | * Generate a help message. 267 | * 268 | * @param {Options} options 269 | * Configuration. 270 | * @returns {string} 271 | * Help message. 272 | */ 273 | function generateHelpMessage(options) { 274 | const extension = options.extensions[0] 275 | const name = options.name 276 | 277 | return [ 278 | inspectAll(schema), 279 | '', 280 | 'Examples:', 281 | '', 282 | ' # Process `input.' + extension + '`', 283 | ' $ ' + name + ' input.' + extension + ' -o output.' + extension, 284 | '', 285 | ' # Pipe', 286 | ' $ ' + name + ' < input.' + extension + ' > output.' + extension, 287 | '', 288 | ' # Rewrite all applicable files', 289 | ' $ ' + name + ' . -o' 290 | ].join('\n') 291 | } 292 | 293 | /** 294 | * Parse configuration as JSON5. 295 | * 296 | * @param {string} value 297 | * Settings. 298 | * @param {Record} cache 299 | * Map to add to. 300 | * @returns {undefined} 301 | * Nothing. 302 | */ 303 | function parseConfig(value, cache) { 304 | /** @type {Record} */ 305 | let flags 306 | /** @type {string} */ 307 | let flag 308 | 309 | try { 310 | flags = json5.parse('{' + value + '}') 311 | } catch (error) { 312 | const cause = /** @type {Error} */ (error) 313 | cause.message = cause.message.replace(/at(?= position)/, 'around') 314 | throw new Error('Cannot parse `' + value + '` as JSON', {cause}) 315 | } 316 | 317 | for (flag in flags) { 318 | if (own.call(flags, flag)) { 319 | cache[flag] = flags[flag] 320 | } 321 | } 322 | } 323 | 324 | /** 325 | * Handle an unknown flag. 326 | * 327 | * @param {string} flag 328 | * Flag. 329 | * @returns {true} 330 | * Returns `true` for flags that are instead part of the files. 331 | * @throws {Error} 332 | * For incorrect cases. 333 | */ 334 | function handleUnknownArgument(flag) { 335 | // Not a glob. 336 | if (flag.charAt(0) === '-') { 337 | // Long options, always unknown. 338 | if (flag.charAt(1) === '-') { 339 | throw new Error( 340 | 'Unknown option `' + flag + '`, expected:\n' + inspectAll(schema) 341 | ) 342 | } 343 | 344 | // Short options, can be grouped. 345 | const found = flag.slice(1).split('') 346 | const known = schema.filter(function (d) { 347 | return d.short 348 | }) 349 | const knownShort = new Set( 350 | known.map(function (d) { 351 | return d.short 352 | }) 353 | ) 354 | let index = -1 355 | 356 | while (++index < found.length) { 357 | const key = found[index] 358 | if (!knownShort.has(key)) { 359 | throw new Error( 360 | 'Unknown short option `-' + key + '`, expected:\n' + inspectAll(known) 361 | ) 362 | } 363 | } 364 | } 365 | 366 | return true 367 | } 368 | 369 | /** 370 | * Inspect all `options`. 371 | * 372 | * @param {Array} fields 373 | * Fields. 374 | * @returns {string} 375 | * Table. 376 | */ 377 | function inspectAll(fields) { 378 | return table( 379 | fields.map(function (d) { 380 | return inspect(d) 381 | }), 382 | { 383 | stringLength(d) { 384 | return stripAnsi(d).length 385 | } 386 | } 387 | ) 388 | } 389 | 390 | /** 391 | * Inspect one field. 392 | * 393 | * @param {Field} field 394 | * Field. 395 | * @returns {Array} 396 | * Cells. 397 | */ 398 | function inspect(field) { 399 | let description = field.description 400 | let long = field.long 401 | 402 | if (field.default === true || field.truelike) { 403 | description += ' (on by default)' 404 | long = '[no-]' + long 405 | } 406 | 407 | long = '--' + long 408 | 409 | if (field.common) { 410 | long = chalk.bold(long) 411 | } 412 | 413 | return [ 414 | '', 415 | field.short ? '-' + field.short : '', 416 | long + (field.value ? ' ' + field.value : ''), 417 | description 418 | ] 419 | } 420 | 421 | /** 422 | * @param {string} value 423 | * Value. 424 | * @returns {[key: string, value?: string]} 425 | * Tuple of a key and an optional value, delimited by `=`. 426 | */ 427 | function splitOptions(value) { 428 | const index = value.indexOf('=') 429 | return index === -1 430 | ? [value] 431 | : [value.slice(0, index), value.slice(index + 1)] 432 | } 433 | 434 | /** 435 | * @template {unknown} T 436 | * Value. 437 | * @param {T} value 438 | * Value. 439 | * @returns {T extends string ? Array : T} 440 | * Value, or an on commas parsed array of it if it’s a string. 441 | */ 442 | function parseIfString(value) { 443 | // @ts-expect-error: this is good. 444 | return typeof value === 'string' ? commaParse(value) : value 445 | } 446 | 447 | /** 448 | * @template {unknown} T 449 | * Value. 450 | * @param {T} value 451 | * Value. 452 | * @returns {T extends Array ? string : T} 453 | * Value, or the last item of it if it’s an array. 454 | */ 455 | function lastIfArray(value) { 456 | return Array.isArray(value) ? value[value.length - 1] : value 457 | } 458 | 459 | /** 460 | * @template {unknown} T 461 | * Value. 462 | * @param {T} value 463 | * Value. 464 | * @returns {T extends Array ? string : T} 465 | * Value, or an on commas joined string of it if it’s an array. 466 | */ 467 | function joinIfArray(value) { 468 | // @ts-expect-error: this is good. 469 | return Array.isArray(value) ? value.join(',') : value 470 | } 471 | 472 | /** 473 | * @template {unknown} T 474 | * Value. 475 | * @param {T} value 476 | * Value. 477 | * @returns {T extends boolean | number | string ? Array : T} 478 | * Value, or an array of it if it’s a non-nully primitive. 479 | */ 480 | function toArray(value) { 481 | // @ts-expect-error: this is good. 482 | return Array.isArray(value) || value === null || value === undefined 483 | ? value 484 | : [value] 485 | } 486 | 487 | /** 488 | * Ignore booleans. 489 | * 490 | * @template {unknown} T 491 | * Value. 492 | * @param {T} value 493 | * Value. 494 | * @returns {T extends boolean ? undefined : T} 495 | * Value, or `undefined` if `value` is a boolean. 496 | */ 497 | function undefinedIfBoolean(value) { 498 | // @ts-expect-error: this is good. 499 | return typeof value === 'boolean' ? undefined : value 500 | } 501 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # unified-args 2 | 3 | [![Build][build-badge]][build] 4 | [![Coverage][coverage-badge]][coverage] 5 | [![Downloads][downloads-badge]][downloads] 6 | [![Sponsors][sponsors-badge]][collective] 7 | [![Backers][backers-badge]][collective] 8 | [![Chat][chat-badge]][chat] 9 | 10 | **[unified][]** engine to create a command line interface from a unified 11 | processor. 12 | 13 | ## Contents 14 | 15 | * [What is this?](#what-is-this) 16 | * [When should I use this?](#when-should-i-use-this) 17 | * [Install](#install) 18 | * [Use](#use) 19 | * [API](#api) 20 | * [`args(options)`](#argsoptions) 21 | * [`Options`](#options) 22 | * [CLI](#cli) 23 | * [Files](#files) 24 | * [`--color`](#--color) 25 | * [`--config`](#--config) 26 | * [`--ext `](#--ext-extensions) 27 | * [`--file-path `](#--file-path-path) 28 | * [`--frail`](#--frail) 29 | * [`--help`](#--help) 30 | * [`--ignore`](#--ignore) 31 | * [`--ignore-path `](#--ignore-path-path) 32 | * [`--ignore-path-resolve-from cwd|dir`](#--ignore-path-resolve-from-cwddir) 33 | * [`--ignore-pattern `](#--ignore-pattern-globs) 34 | * [`--inspect`](#--inspect) 35 | * [`--output [path]`](#--output-path) 36 | * [`--quiet`](#--quiet) 37 | * [`--rc-path `](#--rc-path-path) 38 | * [`--report `](#--report-reporter) 39 | * [`--setting `](#--setting-settings) 40 | * [`--silent`](#--silent) 41 | * [`--silently-ignore`](#--silently-ignore) 42 | * [`--stdout`](#--stdout) 43 | * [`--tree`](#--tree) 44 | * [`--tree-in`](#--tree-in) 45 | * [`--tree-out`](#--tree-out) 46 | * [`--use `](#--use-plugin) 47 | * [`--verbose`](#--verbose) 48 | * [`--version`](#--version) 49 | * [`--watch`](#--watch) 50 | * [Diagnostics](#diagnostics) 51 | * [Debugging](#debugging) 52 | * [Types](#types) 53 | * [Compatibility](#compatibility) 54 | * [Security](#security) 55 | * [Contribute](#contribute) 56 | * [License](#license) 57 | 58 | ## What is this? 59 | 60 | This package wraps [`unified-engine`][unified-engine] so that it can be used 61 | to create a command line interface. 62 | It’s what you use underneath when you use [`remark-cli`][remark-cli]. 63 | 64 | ## When should I use this? 65 | 66 | You can use this to let users process multiple files from the command line, 67 | letting them configure from the file system. 68 | 69 | ## Install 70 | 71 | This package is [ESM only][esm]. 72 | In Node.js (version 16+), install with [npm][]: 73 | 74 | ```sh 75 | npm install unified-args 76 | ``` 77 | 78 | ## Use 79 | 80 | The following example creates a CLI for [remark][], which will search for files 81 | in folders with a markdown extension, allows [configuration][config-file] from 82 | `.remarkrc` and `package.json` files, [ignoring files][ignore-file] from 83 | `.remarkignore` files, and more. 84 | 85 | Say our module `example.js` looks as follows: 86 | 87 | ```js 88 | import {remark} from 'remark' 89 | import {args} from 'unified-args' 90 | 91 | args({ 92 | description: 93 | 'Command line interface to inspect and change markdown files with remark', 94 | extensions: [ 95 | 'md', 96 | 'markdown', 97 | 'mdown', 98 | 'mkdn', 99 | 'mkd', 100 | 'mdwn', 101 | 'mkdown', 102 | 'ron' 103 | ], 104 | ignoreName: '.remarkignore', 105 | name: 'remark', 106 | packageField: 'remarkConfig', 107 | pluginPrefix: 'remark', 108 | processor: remark, 109 | rcName: '.remarkrc', 110 | version: '11.0.0' 111 | }) 112 | ``` 113 | 114 | …now running `node example.js --help` yields: 115 | 116 | ```txt 117 | Usage: remark [options] [path | glob ...] 118 | 119 | Command line interface to inspect and change markdown files with remark 120 | 121 | Options: 122 | 123 | --[no-]color specify color in report (on by default) 124 | --[no-]config search for configuration files (on by default) 125 | -e --ext specify extensions 126 | … 127 | ``` 128 | 129 | ## API 130 | 131 | This package exports the identifier [`args`][api-args]. 132 | There is no default export. 133 | 134 | ### `args(options)` 135 | 136 | Start the CLI. 137 | 138 | > 👉 **Note**: this takes over the entire process. 139 | > It parses `process.argv`, exits when its done, etc. 140 | 141 | ###### Parameters 142 | 143 | * `options` ([`Options`][api-options], required) 144 | — configuration 145 | 146 | ###### Returns 147 | 148 | Nothing (`undefined`). 149 | 150 | ### `Options` 151 | 152 | Configuration (TypeScript type). 153 | 154 | ###### Fields 155 | 156 | 157 | 158 | * `description` (`string`, required) 159 | — description of executable 160 | * `extensions` (`Array`, required) 161 | — default file extensions to include 162 | (engine: `options.extensions`) 163 | * `ignoreName` (`string`, required) 164 | — name of [ignore files][ignore-file] to load 165 | (engine: `options.ignoreName`) 166 | * `name` (`string`, required) 167 | — name of executable 168 | * `packageField` (`string`, required) 169 | — field where [configuration][config-file] can be found in `package.json`s 170 | (engine: `options.packageField`) 171 | * `pluginPrefix` (`string`, required) 172 | — prefix to use when searching for plugins 173 | (engine: `options.pluginPrefix`) 174 | * `processor` ([`Processor`][unified-processor], required) 175 | — processor to use 176 | (engine: `options.processor`) 177 | * `rcName` (`string`, required) 178 | — name of [configuration files][config-file] to load 179 | (engine: `options.rcName`) 180 | * `version` (`string`, required) 181 | — version of executable 182 | 183 | ## CLI 184 | 185 | CLIs created with `unified-args`, such as the [example][] above, have an 186 | interface similar to the below: 187 | 188 | ```txt 189 | Usage: remark [options] [path | glob ...] 190 | 191 | Command line interface to inspect and change markdown files with remark 192 | 193 | Options: 194 | 195 | --[no-]color specify color in report (on by default) 196 | --[no-]config search for configuration files (on by default) 197 | -e --ext specify extensions 198 | --file-path specify path to process as 199 | -f --frail exit with 1 on warnings 200 | -h --help output usage information 201 | --[no-]ignore search for ignore files (on by default) 202 | -i --ignore-path specify ignore file 203 | --ignore-path-resolve-from cwd|dir resolve patterns in `ignore-path` from its directory or cwd 204 | --ignore-pattern specify ignore patterns 205 | --inspect output formatted syntax tree 206 | -o --output [path] specify output location 207 | -q --quiet output only warnings and errors 208 | -r --rc-path specify configuration file 209 | --report specify reporter 210 | -s --setting specify settings 211 | -S --silent output only errors 212 | --silently-ignore do not fail when given ignored files 213 | --[no-]stdout specify writing to stdout (on by default) 214 | -t --tree specify input and output as syntax tree 215 | --tree-in specify input as syntax tree 216 | --tree-out output syntax tree 217 | -u --use use plugins 218 | --verbose report extra info for messages 219 | -v --version output version number 220 | -w --watch watch for changes and reprocess 221 | 222 | Examples: 223 | 224 | # Process `input.md` 225 | $ remark input.md -o output.md 226 | 227 | # Pipe 228 | $ remark < input.md > output.md 229 | 230 | # Rewrite all applicable files 231 | $ remark . -o 232 | ``` 233 | 234 | ### Files 235 | 236 | All non-options passed to the cli are seen as input and can be: 237 | 238 | * paths (`readme.txt`) and [globs][glob] (`*.txt`) pointing to files to load 239 | * paths (`test`) and globs (`fixtures/{in,out}/`) pointing to folders, which 240 | are searched for files with known extensions which are not ignored 241 | by patterns in [ignore files][ignore-file]. 242 | The default behavior is to exclude files in `node_modules/` unless 243 | explicitly given 244 | 245 | You can force things to be seen as input by using `--`: 246 | 247 | ```sh 248 | cli -- globs/* and/files 249 | ``` 250 | 251 | * **default**: none 252 | * **engine**: `options.files` 253 | 254 | ### `--color` 255 | 256 | ```sh 257 | cli --no-color input.txt 258 | ``` 259 | 260 | Whether to output ANSI color codes in the report. 261 | 262 | * **default**: whether the terminal [supports color][supports-color] 263 | * **engine**: `options.color` 264 | 265 | > 👉 **Note**: This option may not work depending on the reporter given in 266 | > [`--report`][cli-report]. 267 | 268 | ### `--config` 269 | 270 | ```sh 271 | cli --no-config input.txt 272 | ``` 273 | 274 | Whether to load [configuration files][config-file]. 275 | 276 | Searches for files with the [configured][api-options] `rcName`: `$rcName` and 277 | `$rcName.json` (JSON), `$rcName.yml` and `$rcName.yaml` (YAML), `$rcName.js` 278 | (JavaScript), `$rcName.cjs` (CommonJS), and `$rcName.mjs` (ESM); and looks for 279 | the configured `packageField` in `package.json` files. 280 | 281 | * **default**: on 282 | * **engine**: `options.detectConfig` 283 | 284 | ### `--ext ` 285 | 286 | ```sh 287 | cli --ext html . 288 | cli --ext htm --ext html . 289 | cli --ext htm,html . 290 | ``` 291 | 292 | Specify one or more extensions to include when searching for files. 293 | 294 | * **default**: [configured][api-options] `extensions` 295 | * **alias**: `-e` 296 | * **engine**: `options.extensions` 297 | 298 | ### `--file-path ` 299 | 300 | ```sh 301 | cli --file-path input.txt < input.txt > doc/output.txt 302 | ``` 303 | 304 | File path to process the given file on **stdin**(4) as, if any. 305 | 306 | * **default**: none 307 | * **engine**: `options.filePath` 308 | 309 | ### `--frail` 310 | 311 | ```sh 312 | cli --frail input.txt 313 | ``` 314 | 315 | Exit with a status code of `1` if warnings or errors occur. 316 | The default behavior is to exit with `1` on errors. 317 | 318 | * **default**: off 319 | * **alias**: `-f` 320 | * **engine**: `options.frail` 321 | 322 | ### `--help` 323 | 324 | ```sh 325 | cli --help 326 | ``` 327 | 328 | Output short usage information. 329 | 330 | * **default**: off 331 | * **alias**: `-h` 332 | 333 | ### `--ignore` 334 | 335 | ```sh 336 | cli --no-ignore . 337 | ``` 338 | 339 | Whether to load [ignore files][ignore-file]. 340 | 341 | Searches for files named [`$ignoreName`][api-options]. 342 | 343 | * **default**: on 344 | * **engine**: `options.detectIgnore` 345 | 346 | ### `--ignore-path ` 347 | 348 | ```sh 349 | cli --ignore-path .gitignore . 350 | ``` 351 | 352 | File path to an [ignore file][ignore-file] to load, regardless of 353 | [`--ignore`][cli-ignore]. 354 | 355 | * **default**: none 356 | * **alias**: `-i` 357 | * **engine**: `options.ignorePath` 358 | 359 | ### `--ignore-path-resolve-from cwd|dir` 360 | 361 | ```sh 362 | cli --ignore-path node_modules/my-config/my-ignore --ignore-path-resolve-from cwd . 363 | ``` 364 | 365 | Resolve patterns in the ignore file from its directory (`dir`, default) or the 366 | current working directory (`cwd`). 367 | 368 | * **default**: `'dir'` 369 | * **engine**: `options.ignorePathResolveFrom` 370 | 371 | ### `--ignore-pattern ` 372 | 373 | ```sh 374 | cli --ignore-pattern "docs/*.md" . 375 | ``` 376 | 377 | Additional patterns to use to ignore files. 378 | 379 | * **default**: none 380 | * **engine**: `options.ignorePatterns` 381 | 382 | ### `--inspect` 383 | 384 | ```sh 385 | cli --inspect < input.txt 386 | ``` 387 | 388 | Output the transformed syntax tree, formatted with 389 | [`unist-util-inspect`][unist-util-inspect]. 390 | This does not run the [compilation phase][overview]. 391 | 392 | * **default**: off 393 | * **engine**: `options.inspect` 394 | 395 | ### `--output [path]` 396 | 397 | ```sh 398 | cli --output -- . 399 | cli --output doc . 400 | cli --output doc/output.text input.txt 401 | ``` 402 | 403 | Whether to write successfully processed files, and where to. 404 | Can be set from [configuration files][config-file]. 405 | 406 | * if output is not given, files are not written to the file system 407 | * otherwise, if `path` is not given, files are overwritten when successful 408 | * otherwise, if `path` points to a folder, files are written there 409 | * otherwise, if one file is processed, the file is written to `path` 410 | 411 | > 👉 **Note**: intermediate folders are not created. 412 | 413 | * **default**: off 414 | * **alias**: `-o` 415 | * **engine**: `options.output` 416 | 417 | ### `--quiet` 418 | 419 | ```sh 420 | cli --quiet input.txt 421 | ``` 422 | 423 | Ignore files without any messages in the report. 424 | The default behavior is to show a success message. 425 | 426 | * **default**: off 427 | * **alias**: `-q` 428 | * **engine**: `options.quiet` 429 | 430 | > 👉 **Note**: this option may not work depending on the reporter given in 431 | > [`--report`][cli-report]. 432 | 433 | ### `--rc-path ` 434 | 435 | ```sh 436 | cli --rc-path config.json . 437 | ``` 438 | 439 | File path to a [configuration file][config-file] to load, regardless of 440 | [`--config`][cli-config]. 441 | 442 | * **default**: none 443 | * **alias**: `-r` 444 | * **engine**: `options.rcPath` 445 | 446 | ### `--report ` 447 | 448 | ```sh 449 | cli --report ./reporter.js input.txt 450 | cli --report vfile-reporter-json input.txt 451 | cli --report json input.txt 452 | cli --report json=pretty:2 input.txt 453 | cli --report 'json=pretty:"\t"' input.txt 454 | # Only last one is used: 455 | cli --report pretty --report json input.txt 456 | ``` 457 | 458 | [Reporter][] to load by its name or path, optionally with options, and use to 459 | report metadata about every processed file. 460 | 461 | To pass options, follow the name by an equals sign (`=`) and settings, which 462 | have the same in syntax as [`--setting `][cli-setting]. 463 | 464 | The prefix `vfile-reporter-` can be omitted. 465 | Prefixed reporters are preferred over modules without prefix. 466 | 467 | If multiple reporters are given, the last one is used. 468 | 469 | * **default**: none, which uses [`vfile-reporter`][vfile-reporter] 470 | * **engine**: `options.reporter` and `options.reporterOptions` 471 | 472 | > 👉 **Note**: the [`quiet`][cli-quiet], [`silent`][cli-silent], and 473 | > [`color`][cli-color] options may not work with the used reporter. 474 | > If they are given, they are preferred over the same properties in reporter 475 | > settings. 476 | 477 | ### `--setting ` 478 | 479 | ```sh 480 | cli --setting alpha:true input.txt 481 | cli --setting bravo:true --setting '"charlie": "delta"' input.txt 482 | cli --setting echo-foxtrot:-2 input.txt 483 | cli --setting 'golf: false, hotel-india: ["juliet", 1]' input.txt 484 | ``` 485 | 486 | Configuration for the parser and compiler of the processor. 487 | Can be set from [configuration files][config-file]. 488 | 489 | The given settings are [JSON5][], with one exception: surrounding braces must 490 | not be used. 491 | Instead, use JSON syntax without braces, such as 492 | `"foo": 1, "bar": "baz"`. 493 | 494 | * **default**: none 495 | * **alias**: `-s` 496 | * **engine**: `options.settings` 497 | 498 | ### `--silent` 499 | 500 | ```sh 501 | cli --silent input.txt 502 | ``` 503 | 504 | Show only fatal errors in the report. 505 | Turns [`--quiet`][cli-quiet] on. 506 | 507 | * **default**: off 508 | * **alias**: `-S` 509 | * **engine**: `options.silent` 510 | 511 | > 👉 **Note**: this option may not work depending on the reporter given in 512 | > [`--report`][cli-report]. 513 | 514 | ### `--silently-ignore` 515 | 516 | ```sh 517 | cli --silently-ignore **/*.md 518 | ``` 519 | 520 | Skip given files which are ignored by ignore files, instead of warning about 521 | them. 522 | 523 | * **default**: off 524 | * **engine**: `options.silentlyIgnore` 525 | 526 | ### `--stdout` 527 | 528 | ```sh 529 | cli --no-stdout input.txt 530 | ``` 531 | 532 | Whether to write a processed file to **stdout**(4). 533 | 534 | * **default**: off if [`--output`][cli-output] or [`--watch`][cli-watch] are 535 | given, or if multiple files could be processed 536 | * **engine**: `options.out` 537 | 538 | ### `--tree` 539 | 540 | ```sh 541 | cli --tree < input.json > output.json 542 | ``` 543 | 544 | Treat input as a syntax tree in JSON and output the transformed syntax tree. 545 | This runs neither the [parsing nor the compilation phase][overview]. 546 | 547 | * **default**: off 548 | * **alias**: `-t` 549 | * **engine**: `options.tree` 550 | 551 | ### `--tree-in` 552 | 553 | ```sh 554 | cli --tree-in < input.json > input.txt 555 | ``` 556 | 557 | Treat input as a syntax tree in JSON. 558 | This does not run the [parsing phase][overview]. 559 | 560 | * **default**: same as `--tree` 561 | * **engine**: `options.treeIn` 562 | 563 | ### `--tree-out` 564 | 565 | ```sh 566 | cli --tree-out < input.txt > output.json 567 | ``` 568 | 569 | Output the transformed syntax tree. 570 | This does not run the [compilation phase][overview]. 571 | 572 | * **default**: same as `--tree` 573 | * **engine**: `options.treeOut` 574 | 575 | ### `--use ` 576 | 577 | ```sh 578 | cli --use remark-man input.txt 579 | cli --use man input.txt 580 | cli --use 'toc=max-depth:3' input.txt 581 | cli --use ./plugin.js input.txt 582 | ``` 583 | 584 | Plugin to load by its name or path, optionally with options, and use on every 585 | processed file. 586 | Can be set from [configuration files][config-file]. 587 | 588 | To pass options, follow the plugin by an equals sign (`=`) and settings, which 589 | have the same in syntax as [`--setting `][cli-setting]. 590 | 591 | Plugins prefixed with the [configured][api-options] `pluginPrefix` are 592 | preferred over modules without prefix. 593 | 594 | * **default**: none 595 | * **alias**: `-u` 596 | * **engine**: `options.plugins` 597 | 598 | ### `--verbose` 599 | 600 | ```sh 601 | cli --verbose input.txt 602 | ``` 603 | 604 | Print more info for messages. 605 | 606 | * **default**: off 607 | * **engine**: `options.verbose` 608 | 609 | > 👉 **Note**: this option may not work depending on the reporter given in 610 | > [`--report`][cli-report]. 611 | 612 | ### `--version` 613 | 614 | ```sh 615 | cli --version 616 | ``` 617 | 618 | Output version number. 619 | 620 | * **default**: off 621 | * **alias**: `-v` 622 | 623 | ### `--watch` 624 | 625 | ```sh 626 | cli -qwo . 627 | ``` 628 | 629 | Yields: 630 | 631 | ```txt 632 | Watching... (press CTRL+C to exit) 633 | Note: Ignoring `--output` until exit. 634 | ``` 635 | 636 | Process as normal, then watch found files and reprocess when they change. 637 | The watch is stopped when `SIGINT` is received (usually done by pressing 638 | `CTRL-C`). 639 | 640 | If [`--output`][cli-output] is given without `path`, it is not honored, to 641 | prevent an infinite loop. 642 | On operating systems other than Windows, when the watch closes, a final process 643 | runs including `--output`. 644 | 645 | * **default**: off 646 | * **alias**: `-w` 647 | 648 | ## Diagnostics 649 | 650 | CLIs created with **unified-args** exit with: 651 | 652 | * `1` on fatal errors 653 | * `1` on warnings in [`--frail`][cli-frail] mode, `0` on warnings otherwise 654 | * `0` on success 655 | 656 | ## Debugging 657 | 658 | CLIs can be debugged by setting the [`DEBUG`][debug] environment variable to 659 | `*`, such as `DEBUG="*" cli example.txt`. 660 | 661 | ## Types 662 | 663 | This package is fully typed with [TypeScript][]. 664 | It export the additional type [`Options`][api-options]. 665 | 666 | ## Compatibility 667 | 668 | Projects maintained by the unified collective are compatible with maintained 669 | versions of Node.js. 670 | 671 | When we cut a new major release, we drop support for unmaintained versions of 672 | Node. 673 | This means we try to keep the current release line, `unified-engine@^11`, 674 | compatible with Node.js 16. 675 | 676 | ## Security 677 | 678 | `unified-args` loads and evaluates configuration files, plugins, and presets 679 | from the file system (often from `node_modules/`). 680 | That means code that is on your file system runs. 681 | Make sure you trust the workspace where you run `unified-args` and be careful 682 | with packages from npm and changes made by contributors. 683 | 684 | ## Contribute 685 | 686 | See [`contributing.md`][contributing] in [`unifiedjs/.github`][health] for ways 687 | to get started. 688 | See [`support.md`][support] for ways to get help. 689 | 690 | This project has a [code of conduct][coc]. 691 | By interacting with this repository, organization, or community you agree to 692 | abide by its terms. 693 | 694 | ## License 695 | 696 | [MIT][license] © [Titus Wormer][author] 697 | 698 | 699 | 700 | [build-badge]: https://github.com/unifiedjs/unified-args/workflows/main/badge.svg 701 | 702 | [build]: https://github.com/unifiedjs/unified-args/actions 703 | 704 | [coverage-badge]: https://img.shields.io/codecov/c/github/unifiedjs/unified-args.svg 705 | 706 | [coverage]: https://codecov.io/github/unifiedjs/unified-args 707 | 708 | [downloads-badge]: https://img.shields.io/npm/dm/unified-args.svg 709 | 710 | [downloads]: https://www.npmjs.com/package/unified-args 711 | 712 | [sponsors-badge]: https://opencollective.com/unified/sponsors/badge.svg 713 | 714 | [backers-badge]: https://opencollective.com/unified/backers/badge.svg 715 | 716 | [collective]: https://opencollective.com/unified 717 | 718 | [chat-badge]: https://img.shields.io/badge/chat-discussions-success.svg 719 | 720 | [chat]: https://github.com/unifiedjs/unified/discussions 721 | 722 | [npm]: https://docs.npmjs.com/cli/install 723 | 724 | [esm]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c 725 | 726 | [typescript]: https://www.typescriptlang.org 727 | 728 | [health]: https://github.com/unifiedjs/.github 729 | 730 | [contributing]: https://github.com/unifiedjs/.github/blob/main/contributing.md 731 | 732 | [support]: https://github.com/unifiedjs/.github/blob/main/support.md 733 | 734 | [coc]: https://github.com/unifiedjs/.github/blob/main/code-of-conduct.md 735 | 736 | [license]: license 737 | 738 | [author]: https://wooorm.com 739 | 740 | [unified]: https://github.com/unifiedjs/unified 741 | 742 | [unified-processor]: https://github.com/unifiedjs/unified#processor 743 | 744 | [overview]: https://github.com/unifiedjs/unified#overview 745 | 746 | [remark]: https://github.com/remarkjs/remark 747 | 748 | [remark-cli]: https://github.com/remarkjs/remark/tree/main/packages/remark-cli 749 | 750 | [reporter]: https://github.com/vfile/vfile#reporters 751 | 752 | [vfile-reporter]: https://github.com/vfile/vfile-reporter 753 | 754 | [unist-util-inspect]: https://github.com/syntax-tree/unist-util-inspect 755 | 756 | [debug]: https://github.com/debug-js/debug 757 | 758 | [glob]: https://github.com/isaacs/node-glob#glob-primer 759 | 760 | [supports-color]: https://github.com/chalk/supports-color 761 | 762 | [json5]: https://github.com/json5/json5 763 | 764 | [unified-engine]: https://github.com/unifiedjs/unified-engine 765 | 766 | [config-file]: https://github.com/unifiedjs/unified-engine#config-files 767 | 768 | [ignore-file]: https://github.com/unifiedjs/unified-engine#ignore-files 769 | 770 | [example]: #use 771 | 772 | [api-args]: #argsoptions 773 | 774 | [api-options]: #options 775 | 776 | [cli-color]: #--color 777 | 778 | [cli-config]: #--config 779 | 780 | [cli-frail]: #--frail 781 | 782 | [cli-ignore]: #--ignore 783 | 784 | [cli-output]: #--output-path 785 | 786 | [cli-quiet]: #--quiet 787 | 788 | [cli-report]: #--report-reporter 789 | 790 | [cli-setting]: #--setting-settings 791 | 792 | [cli-silent]: #--silent 793 | 794 | [cli-watch]: #--watch 795 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {ExecaError} from 'execa' 3 | */ 4 | 5 | import assert from 'node:assert/strict' 6 | import fs from 'node:fs/promises' 7 | import path from 'node:path' 8 | import {EOL} from 'node:os' 9 | import {platform} from 'node:process' 10 | import test from 'node:test' 11 | import {fileURLToPath} from 'node:url' 12 | import {execa} from 'execa' 13 | import stripAnsi from 'strip-ansi' 14 | 15 | const base = new URL('fixtures/example/', import.meta.url) 16 | const binaryUrl = new URL('fixtures/example/cli.js', import.meta.url) 17 | const binaryPath = fileURLToPath(binaryUrl) 18 | 19 | test('args', async function (t) { 20 | // Clean from last run. 21 | try { 22 | await fs.unlink(new URL('watch.txt', base)) 23 | } catch {} 24 | 25 | const help = String(await fs.readFile(new URL('HELP', base))) 26 | .replace(/\r\n/g, '\n') 27 | .trimEnd() 28 | const longFlag = String(await fs.readFile(new URL('LONG_FLAG', base))) 29 | .replace(/\r\n/g, '\n') 30 | .trimEnd() 31 | const shortFlag = String(await fs.readFile(new URL('SHORT_FLAG', base))) 32 | .replace(/\r\n/g, '\n') 33 | .trimEnd() 34 | 35 | await t.test('should expose the public api', async function () { 36 | assert.deepEqual(Object.keys(await import('unified-args')).sort(), ['args']) 37 | }) 38 | 39 | await t.test('should fail on missing files', async function () { 40 | try { 41 | await execa(binaryPath, ['missing.txt']) 42 | assert.fail() 43 | } catch (error) { 44 | const result = /** @type {ExecaError} */ (error) 45 | assert.deepEqual( 46 | [result.exitCode, cleanError(result.stderr)], 47 | [ 48 | 1, 49 | [ 50 | 'missing.txt', 51 | ' error No such file or folder', 52 | ' [cause]:', 53 | ' Error: ENOENT:…', 54 | '', 55 | '✖ 1 error' 56 | ].join('\n') 57 | ] 58 | ) 59 | } 60 | }) 61 | 62 | await t.test('should accept a path to a file', async function () { 63 | const result = await execa(binaryPath, ['one.txt']) 64 | 65 | assert.deepEqual( 66 | [result.stdout, cleanError(result.stderr)], 67 | ['one', 'one.txt: no issues found'] 68 | ) 69 | }) 70 | 71 | await t.test('should accept a path to a directory', async function () { 72 | const result = await execa(binaryPath, ['.']) 73 | 74 | assert.deepEqual( 75 | [result.stdout, cleanError(result.stderr)], 76 | [ 77 | '', 78 | [ 79 | 'one.txt: no issues found', 80 | 'three' + path.sep + 'five.txt: no issues found', 81 | 'three' + path.sep + 'four.txt: no issues found', 82 | 'two.txt: no issues found' 83 | ].join('\n') 84 | ] 85 | ) 86 | }) 87 | 88 | await t.test('should accept a glob to files', async function () { 89 | const result = await execa(binaryPath, ['*.txt']) 90 | 91 | assert.deepEqual( 92 | [result.stdout, cleanError(result.stderr)], 93 | ['', 'one.txt: no issues found\ntwo.txt: no issues found'] 94 | ) 95 | }) 96 | 97 | await t.test('should accept a glob to a directory', async function () { 98 | const result = await execa(binaryPath, ['thr+(e)']) 99 | 100 | assert.deepEqual( 101 | [result.stdout, cleanError(result.stderr)], 102 | [ 103 | '', 104 | [ 105 | 'three' + path.sep + 'five.txt: no issues found', 106 | 'three' + path.sep + 'four.txt: no issues found' 107 | ].join('\n') 108 | ] 109 | ) 110 | }) 111 | 112 | await t.test('should fail on a bad short flag', async function () { 113 | try { 114 | await execa(binaryPath, ['-n']) 115 | assert.fail() 116 | } catch (error) { 117 | const result = /** @type {ExecaError} */ (error) 118 | 119 | assert.deepEqual( 120 | [result.exitCode, cleanError(result.stderr, 14)], 121 | [1, shortFlag] 122 | ) 123 | } 124 | }) 125 | 126 | await t.test('should fail on a bad grouped short flag', async function () { 127 | try { 128 | await execa(binaryPath, ['-on']) 129 | assert.fail() 130 | } catch (error) { 131 | const result = /** @type {ExecaError} */ (error) 132 | 133 | assert.deepEqual( 134 | [result.exitCode, cleanError(result.stderr, 14)], 135 | [1, shortFlag] 136 | ) 137 | } 138 | }) 139 | 140 | await t.test('should fail on a bad long flag', async function () { 141 | try { 142 | await execa(binaryPath, ['--no']) 143 | assert.fail() 144 | } catch (error) { 145 | const result = /** @type {ExecaError} */ (error) 146 | 147 | assert.deepEqual( 148 | [result.exitCode, cleanError(result.stderr, 27)], 149 | [1, longFlag] 150 | ) 151 | } 152 | }) 153 | 154 | await helpFlag('-h') 155 | await helpFlag('--help') 156 | 157 | /** 158 | * @param {string} flag 159 | * Flag. 160 | * @returns {Promise} 161 | * Nothing. 162 | */ 163 | async function helpFlag(flag) { 164 | await t.test('should show help on `' + flag + '`', async function () { 165 | const result = await execa(binaryPath, [flag]) 166 | assert.deepEqual([result.stdout, result.stderr], [help, '']) 167 | }) 168 | } 169 | 170 | await versionFlag('-v') 171 | await versionFlag('--version') 172 | 173 | /** 174 | * @param {string} flag 175 | * Flag. 176 | * @returns {Promise} 177 | * Nothing. 178 | */ 179 | async function versionFlag(flag) { 180 | await t.test('should show version on `' + flag + '`', async function () { 181 | const result = await execa(binaryPath, [flag]) 182 | 183 | assert.deepEqual([result.stdout, result.stderr], ['0.0.0', '']) 184 | }) 185 | } 186 | 187 | await t.test('should support `--color`', async function () { 188 | const result = await execa(binaryPath, ['--color', 'one.txt']) 189 | 190 | assert.deepEqual( 191 | [result.stdout, result.stderr], 192 | ['one', '\u001B[4m\u001B[32mone.txt\u001B[39m\u001B[24m: no issues found'] 193 | ) 194 | }) 195 | 196 | await t.test('should support `--no-color`', async function () { 197 | const result = await execa(binaryPath, ['--no-color', 'one.txt']) 198 | 199 | assert.deepEqual( 200 | [result.stdout, result.stderr], 201 | ['one', 'one.txt: no issues found'] 202 | ) 203 | }) 204 | 205 | await extensionFlag('-e') 206 | await extensionFlag('--ext') 207 | 208 | /** 209 | * @param {string} flag 210 | * Flag. 211 | * @returns {Promise} 212 | * Nothing. 213 | */ 214 | async function extensionFlag(flag) { 215 | await t.test('should support `' + flag + '`', async function () { 216 | const result = await execa(binaryPath, ['.', flag, 'text']) 217 | 218 | assert.deepEqual( 219 | [result.stdout, cleanError(result.stderr)], 220 | [ 221 | '', 222 | [ 223 | 'alpha.text: no issues found', 224 | 'bravo.text: no issues found', 225 | 'charlie' + path.sep + 'delta.text: no issues found', 226 | 'charlie' + path.sep + 'echo.text: no issues found', 227 | 'delta.text: no issues found' 228 | ].join('\n') 229 | ] 230 | ) 231 | }) 232 | 233 | await t.test( 234 | 'should fail on `' + flag + '` without value', 235 | async function () { 236 | try { 237 | await execa(binaryPath, ['.', flag]) 238 | assert.fail() 239 | } catch (error) { 240 | const result = /** @type {ExecaError} */ (error) 241 | 242 | assert.deepEqual( 243 | [result.exitCode, cleanError(result.stderr, 1)], 244 | [ 245 | 1, 246 | 'Error: Missing value: -e --ext specify extensions' 247 | ] 248 | ) 249 | } 250 | } 251 | ) 252 | 253 | await t.test( 254 | 'should allow an extra `-e` after `' + flag + '`', 255 | async function () { 256 | const result = await execa(binaryPath, ['.', flag, 'text', '-e']) 257 | 258 | assert.deepEqual( 259 | [result.stdout, cleanError(result.stderr)], 260 | [ 261 | '', 262 | [ 263 | 'alpha.text: no issues found', 264 | 'bravo.text: no issues found', 265 | 'charlie' + path.sep + 'delta.text: no issues found', 266 | 'charlie' + path.sep + 'echo.text: no issues found', 267 | 'delta.text: no issues found' 268 | ].join('\n') 269 | ] 270 | ) 271 | } 272 | ) 273 | } 274 | 275 | await settingsFlag('-s') 276 | await settingsFlag('--setting') 277 | 278 | /** 279 | * @param {string} flag 280 | * Flag. 281 | * @returns {Promise} 282 | * Nothing. 283 | */ 284 | async function settingsFlag(flag) { 285 | await t.test( 286 | 'should catch syntax errors in `' + flag + '`', 287 | async function () { 288 | try { 289 | // Should be quoted. 290 | await execa(binaryPath, ['.', flag, 'foo:bar']) 291 | assert.fail() 292 | } catch (error) { 293 | const result = /** @type {ExecaError} */ (error) 294 | 295 | assert.deepEqual( 296 | [result.exitCode, cleanError(result.stderr, 1)], 297 | [1, 'Error: Cannot parse `foo:bar` as JSON'] 298 | ) 299 | } 300 | } 301 | ) 302 | 303 | await t.test('should support `' + flag + '`', async function () { 304 | const binaryPath = fileURLToPath( 305 | new URL('fixtures/settings/cli.js', import.meta.url) 306 | ) 307 | 308 | const result = await execa(binaryPath, [ 309 | 'one.txt', 310 | flag, 311 | '"foo-bar":"baz"' 312 | ]) 313 | 314 | // Parser and Compiler both log stringified settings. 315 | assert.deepEqual( 316 | [result.stdout, cleanError(result.stderr)], 317 | ['{"foo-bar":"baz"}\none', 'one.txt: no issues found'] 318 | ) 319 | }) 320 | } 321 | 322 | await t.test('should not fail on property-like settings', async function () { 323 | const binaryPath = fileURLToPath( 324 | new URL('fixtures/settings/cli.js', import.meta.url) 325 | ) 326 | 327 | const result = await execa(binaryPath, [ 328 | '.', 329 | '--setting', 330 | 'foo:"https://example.com"' 331 | ]) 332 | 333 | assert.deepEqual( 334 | [result.stdout, cleanError(result.stderr)], 335 | ['{"foo":"https://example.com"}', 'one.txt: no issues found'] 336 | ) 337 | }) 338 | 339 | await t.test('should ignore boolean settings', async function () { 340 | const binaryPath = fileURLToPath( 341 | new URL('fixtures/settings/cli.js', import.meta.url) 342 | ) 343 | 344 | const result = await execa(binaryPath, ['.', '--no-setting']) 345 | 346 | assert.deepEqual( 347 | [result.stdout, cleanError(result.stderr)], 348 | ['{}', 'one.txt: no issues found'] 349 | ) 350 | }) 351 | 352 | await useFlag('-u') 353 | await useFlag('--use') 354 | 355 | /** 356 | * @param {string} flag 357 | * Flag. 358 | * @returns {Promise} 359 | * Nothing. 360 | */ 361 | async function useFlag(flag) { 362 | await t.test('should load a plugin with `' + flag + '`', async function () { 363 | const binaryPath = fileURLToPath( 364 | new URL('fixtures/plugins/cli.js', import.meta.url) 365 | ) 366 | 367 | const result = await execa(binaryPath, ['one.txt', flag, './plugin.js']) 368 | 369 | // Plugin logs options, which are `undefined`. 370 | assert.deepEqual( 371 | [result.stdout, cleanError(result.stderr)], 372 | ['undefined\none', 'one.txt: no issues found'] 373 | ) 374 | }) 375 | 376 | await t.test( 377 | 'should catch syntax errors in `' + flag + '`', 378 | async function () { 379 | // Should be quoted. 380 | try { 381 | await execa(binaryPath, ['.', flag, './plugin.js=foo:bar']) 382 | assert.fail() 383 | } catch (error) { 384 | const result = /** @type {ExecaError} */ (error) 385 | 386 | assert.deepEqual( 387 | [result.exitCode, cleanError(result.stderr, 1)], 388 | [1, 'Error: Cannot parse `foo:bar` as JSON'] 389 | ) 390 | } 391 | } 392 | ) 393 | 394 | await t.test('should support `' + flag + '`', async function () { 395 | const binaryPath = fileURLToPath( 396 | new URL('fixtures/plugins/cli.js', import.meta.url) 397 | ) 398 | 399 | const result = await execa(binaryPath, [ 400 | 'one.txt', 401 | flag, 402 | './plugin.js=foo:{bar:"baz",qux:1,quux:true}' 403 | ]) 404 | 405 | assert.deepEqual( 406 | [result.stdout, cleanError(result.stderr)], 407 | [ 408 | '{"foo":{"bar":"baz","qux":1,"quux":true}}\none', 409 | 'one.txt: no issues found' 410 | ] 411 | ) 412 | }) 413 | } 414 | 415 | await t.test('should support `--report`', async function () { 416 | const result = await execa(binaryPath, ['alpha.text', '--report', 'json']) 417 | 418 | assert.deepEqual( 419 | [result.stdout, result.stderr], 420 | [ 421 | 'alpha', 422 | JSON.stringify([ 423 | { 424 | path: 'alpha.text', 425 | cwd: 'test' + path.sep + 'fixtures' + path.sep + 'example', 426 | history: ['alpha.text'], 427 | messages: [] 428 | } 429 | ]) 430 | ] 431 | ) 432 | }) 433 | 434 | await t.test('should support `--report` with options', async function () { 435 | const result = await execa(binaryPath, [ 436 | 'alpha.text', 437 | '--report', 438 | 'json=pretty:"\t"' 439 | ]) 440 | 441 | assert.deepEqual( 442 | [result.stdout, result.stderr], 443 | [ 444 | 'alpha', 445 | JSON.stringify( 446 | [ 447 | { 448 | path: 'alpha.text', 449 | cwd: 'test' + path.sep + 'fixtures' + path.sep + 'example', 450 | history: ['alpha.text'], 451 | messages: [] 452 | } 453 | ], 454 | undefined, 455 | '\t' 456 | ) 457 | ] 458 | ) 459 | }) 460 | 461 | await t.test('should fail on `--report` without value', async function () { 462 | try { 463 | await execa(binaryPath, ['.', '--report']) 464 | assert.fail() 465 | } catch (error) { 466 | const result = /** @type {ExecaError} */ (error) 467 | 468 | assert.deepEqual( 469 | [result.exitCode, cleanError(result.stderr, 1)], 470 | [1, 'Error: Missing value: --report specify reporter'] 471 | ) 472 | } 473 | }) 474 | 475 | await t.test('should support `--no-stdout`', async function () { 476 | const result = await execa(binaryPath, ['one.txt', '--no-stdout']) 477 | 478 | assert.deepEqual( 479 | [result.stdout, cleanError(result.stderr)], 480 | ['', 'one.txt: no issues found'] 481 | ) 482 | }) 483 | 484 | await t.test('should support `--tree-in`', async function () { 485 | const result = await execa(binaryPath, ['tree.json', '--tree-in']) 486 | 487 | assert.deepEqual( 488 | [result.stdout, cleanError(result.stderr)], 489 | ['hi!', 'tree.json: no issues found'] 490 | ) 491 | }) 492 | 493 | await t.test('should support `--tree-out`', async function () { 494 | const result = await execa(binaryPath, ['one.txt', '--tree-out']) 495 | 496 | assert.deepEqual( 497 | [result.stdout, cleanError(result.stderr)], 498 | [ 499 | JSON.stringify({type: 'text', value: 'one' + EOL}, undefined, 2), 500 | 'one.txt: no issues found' 501 | ] 502 | ) 503 | }) 504 | 505 | await t.test('should support `--tree`', async function () { 506 | const result = await execa(binaryPath, ['tree.json', '--tree']) 507 | 508 | assert.deepEqual( 509 | [result.stdout, cleanError(result.stderr)], 510 | [ 511 | '{\n "type": "text",\n "value": "hi!"\n}', 512 | 'tree.json: no issues found' 513 | ] 514 | ) 515 | }) 516 | 517 | await t.test('should support `--ignore-pattern`', async function () { 518 | const result = await execa(binaryPath, [ 519 | '.', 520 | '--ext', 521 | 'txt,text', 522 | '--ignore-pattern', 523 | 'charlie/*,three/*.txt,delta.*' 524 | ]) 525 | 526 | assert.deepEqual( 527 | [result.stdout, cleanError(result.stderr)], 528 | [ 529 | '', 530 | [ 531 | 'alpha.text: no issues found', 532 | 'bravo.text: no issues found', 533 | 'one.txt: no issues found', 534 | 'two.txt: no issues found' 535 | ].join('\n') 536 | ] 537 | ) 538 | }) 539 | 540 | await t.test('should support `--ignore-path`', async function () { 541 | const result = await execa(binaryPath, [ 542 | '.', 543 | '--ext', 544 | 'text', 545 | '--ignore-path', 546 | 'charlie' + path.sep + 'ignore' 547 | ]) 548 | 549 | assert.deepEqual( 550 | [result.stdout, cleanError(result.stderr)], 551 | [ 552 | '', 553 | [ 554 | 'alpha.text: no issues found', 555 | 'bravo.text: no issues found', 556 | 'charlie' + path.sep + 'echo.text: no issues found', 557 | 'delta.text: no issues found' 558 | ].join('\n') 559 | ] 560 | ) 561 | }) 562 | 563 | await t.test('should ignore non-last `--ignore-path`s', async function () { 564 | const result = await execa(binaryPath, [ 565 | '.', 566 | '--ext', 567 | 'text', 568 | '--ignore-path', 569 | 'missing', 570 | '--ignore-path', 571 | 'charlie' + path.sep + 'ignore' 572 | ]) 573 | 574 | assert.deepEqual( 575 | [result.stdout, cleanError(result.stderr)], 576 | [ 577 | '', 578 | [ 579 | 'alpha.text: no issues found', 580 | 'bravo.text: no issues found', 581 | 'charlie' + path.sep + 'echo.text: no issues found', 582 | 'delta.text: no issues found' 583 | ].join('\n') 584 | ] 585 | ) 586 | }) 587 | 588 | await t.test( 589 | 'should support `--ignore-path-resolve-from cwd`', 590 | async function () { 591 | const result = await execa(binaryPath, [ 592 | '.', 593 | '--ext', 594 | 'text', 595 | '--ignore-path', 596 | 'charlie' + path.sep + 'ignore', 597 | '--ignore-path-resolve-from', 598 | 'cwd' 599 | ]) 600 | 601 | assert.deepEqual( 602 | [result.stdout, cleanError(result.stderr)], 603 | [ 604 | '', 605 | [ 606 | 'alpha.text: no issues found', 607 | 'bravo.text: no issues found', 608 | 'charlie' + path.sep + 'echo.text: no issues found' 609 | ].join('\n') 610 | ] 611 | ) 612 | } 613 | ) 614 | 615 | await t.test('should fail when given an ignored path', async function () { 616 | try { 617 | await execa(binaryPath, [ 618 | 'one.txt', 619 | 'two.txt', 620 | '--ignore-pattern', 621 | 'one.txt' 622 | ]) 623 | assert.fail() 624 | } catch (error) { 625 | const result = /** @type {ExecaError} */ (error) 626 | 627 | assert.deepEqual( 628 | [result.exitCode, cleanError(result.stderr)], 629 | [ 630 | 1, 631 | [ 632 | 'one.txt', 633 | ' error Cannot process specified file: it’s ignored', 634 | '', 635 | 'two.txt: no issues found', 636 | '', 637 | '✖ 1 error' 638 | ].join('\n') 639 | ] 640 | ) 641 | } 642 | }) 643 | 644 | await t.test( 645 | 'should fail when given an incorrect `ignore-path-resolve-from`', 646 | async function () { 647 | try { 648 | await execa(binaryPath, [ 649 | 'one.txt', 650 | '--ignore-path-resolve-from', 651 | 'xyz' 652 | ]) 653 | assert.fail() 654 | } catch (error) { 655 | const result = /** @type {ExecaError} */ (error) 656 | 657 | assert.deepEqual( 658 | [result.exitCode, cleanError(result.stderr, 1)], 659 | [ 660 | 1, 661 | "Error: Expected `'cwd'` or `'dir'` for `ignore-path-resolve-from`, not: `xyz`" 662 | ] 663 | ) 664 | } 665 | } 666 | ) 667 | 668 | await t.test('should support `--silently-ignore`', async function () { 669 | const result = await execa(binaryPath, [ 670 | 'one.txt', 671 | 'two.txt', 672 | '--ignore-pattern', 673 | 'one.txt', 674 | '--silently-ignore' 675 | ]) 676 | 677 | assert.deepEqual( 678 | [result.stdout, cleanError(result.stderr)], 679 | ['', 'two.txt: no issues found'] 680 | ) 681 | }) 682 | 683 | await t.test('should support `--watch`', async function () { 684 | // On Windows, `SIGINT` crashes immediately and results in an error. 685 | // Hence `reject: false`, `exitCode`, and extra lines when non-windows. 686 | const delay = 3000 687 | const url = new URL('watch.txt', base) 688 | 689 | await fs.writeFile(url, 'alpha') 690 | 691 | const processPromise = execa(binaryPath, ['watch.txt', '-w'], { 692 | reject: false 693 | }) 694 | 695 | setTimeout(seeYouLaterAlligator, delay) 696 | 697 | const result = await processPromise 698 | 699 | await fs.unlink(url) 700 | 701 | const lines = [ 702 | 'Watching... (press CTRL+C to exit)', 703 | 'watch.txt: no issues found', 704 | 'watch.txt: no issues found' 705 | ] 706 | 707 | if (platform === 'win32') { 708 | // Empty. 709 | } else { 710 | lines.push('') 711 | } 712 | 713 | assert.equal(result.exitCode, platform === 'win32' ? undefined : 0) 714 | assert.equal(result.stdout, '') 715 | assert.equal(cleanError(result.stderr), lines.join('\n')) 716 | 717 | async function seeYouLaterAlligator() { 718 | await fs.writeFile(url, 'bravo') 719 | setTimeout(afterAWhileCrocodile, delay) 720 | } 721 | 722 | function afterAWhileCrocodile() { 723 | processPromise.kill('SIGINT') 724 | } 725 | }) 726 | 727 | await t.test('should not regenerate when watching', async function () { 728 | const delay = 3000 729 | const url = new URL('watch.txt', base) 730 | 731 | await fs.writeFile(url, 'alpha') 732 | 733 | const processPromise = execa(binaryPath, ['watch.txt', '-w', '-o'], { 734 | reject: false 735 | }) 736 | 737 | setTimeout(seeYouLaterAlligator, delay) 738 | 739 | const result = await processPromise 740 | 741 | await fs.unlink(url) 742 | 743 | const lines = [ 744 | 'Watching... (press CTRL+C to exit)', 745 | 'Note: Ignoring `--output` until exit.', 746 | 'watch.txt: no issues found', 747 | 'watch.txt: no issues found' 748 | ] 749 | 750 | if (platform !== 'win32') { 751 | lines.push('', 'watch.txt: written') 752 | } 753 | 754 | assert.equal(result.exitCode, platform === 'win32' ? undefined : 0) 755 | assert.equal(result.stdout, '') 756 | assert.equal(cleanError(result.stderr), lines.join('\n')) 757 | 758 | async function seeYouLaterAlligator() { 759 | await fs.writeFile(url, 'bravo') 760 | setTimeout(afterAWhileCrocodile, delay) 761 | } 762 | 763 | function afterAWhileCrocodile() { 764 | processPromise.kill('SIGINT') 765 | } 766 | }) 767 | 768 | await t.test('should exit on fatal errors when watching', async function () { 769 | try { 770 | await execa(binaryPath, ['-w']) 771 | assert.fail() 772 | } catch (error) { 773 | const result = /** @type {ExecaError} */ (error) 774 | 775 | assert.deepEqual( 776 | [result.exitCode, cleanError(result.stderr, 2)], 777 | [1, 'Watching... (press CTRL+C to exit)\nError: No input'] 778 | ) 779 | } 780 | }) 781 | 782 | await t.test('should report uncaught exceptions', async function () { 783 | const binaryPath = fileURLToPath( 784 | new URL('fixtures/uncaught-errors/cli.js', import.meta.url) 785 | ) 786 | 787 | try { 788 | await execa(binaryPath, ['.', '-u', './plugin.js']) 789 | assert.fail() 790 | } catch (error) { 791 | const result = /** @type {ExecaError} */ (error) 792 | 793 | assert.deepEqual( 794 | [result.exitCode, cleanError(result.stderr)], 795 | [1, 'one.txt: no issues found\nfoo'] 796 | ) 797 | } 798 | }) 799 | }) 800 | 801 | /** 802 | * Clean an error so that it’s easier to test. 803 | * 804 | * This particularly removed error cause messages, which change across Node 805 | * versions. 806 | * It also drops file paths, which differ across platforms. 807 | * 808 | * @param {string} value 809 | * Error, report, or stack. 810 | * @param {number | undefined} [max=Infinity] 811 | * Lines to include. 812 | * @returns {string} 813 | * Clean error. 814 | */ 815 | function cleanError(value, max) { 816 | return ( 817 | stripAnsi(value) 818 | // Clean syscal errors 819 | .replace(/( *Error: [A-Z]+:)[^\n]*/g, '$1…') 820 | 821 | .replace(/\(.+[/\\]/g, '(') 822 | .replace(/file:.+\//g, '') 823 | .replace(/\d+:\d+/g, '1:1') 824 | .split('\n') 825 | .slice(0, max || Number.POSITIVE_INFINITY) 826 | .join('\n') 827 | ) 828 | } 829 | --------------------------------------------------------------------------------