├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── SECURITY.md ├── doc ├── hello.txt └── help.txt ├── example.js ├── fixture ├── basic │ ├── hello.txt │ └── help.txt ├── dir │ └── a │ │ └── b.txt ├── no-ext │ └── hello ├── sameprefix │ ├── hello world.txt │ └── hello.txt └── shortnames │ ├── abcde fghi lmno.txt │ ├── abcde hello.txt │ └── hello world.txt ├── help-me.js ├── package.json └── test.js /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | 9 | strategy: 10 | matrix: 11 | os: [ubuntu-latest, windows-latest] 12 | node-version: [14.x, 16.x, 18.x, 20.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: Use Node.js 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | 22 | - name: Install 23 | run: | 24 | npm install 25 | 26 | - name: Run tests 27 | run: | 28 | npm run test 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2022 Matteo Collina 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 all 13 | 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 THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | help-me 2 | ======= 3 | 4 | Help command for node, to use with [minimist](http://npm.im/minimist) and [commist](http://npm.im/commist). 5 | 6 | Example 7 | ------- 8 | 9 | ```js 10 | 'use strict' 11 | 12 | var helpMe = require('help-me') 13 | var path = require('path') 14 | var help = helpMe({ 15 | dir: path.join(__dirname, 'doc'), 16 | // the default 17 | ext: '.txt' 18 | }) 19 | 20 | help 21 | .createStream(['hello']) // can support also strings 22 | .pipe(process.stdout) 23 | 24 | // little helper to do the same 25 | help.toStdout(['hello']) 26 | ``` 27 | 28 | Using ESM and top-level await:: 29 | 30 | ```js 31 | import { help } from 'help-me' 32 | import { join } from 'desm' 33 | 34 | await help({ 35 | dir: join(import.meta.url, 'doc'), 36 | // the default 37 | ext: '.txt' 38 | }, ['hello']) 39 | ``` 40 | 41 | Usage with commist 42 | ------------------ 43 | 44 | [Commist](http://npm.im/commist) provide a command system for node. 45 | 46 | ```js 47 | var commist = require('commist')() 48 | var path = require('path') 49 | var help = require('help-me')({ 50 | dir: path.join(__dirname, 'doc') 51 | }) 52 | 53 | commist.register('help', help.toStdout) 54 | 55 | commist.parse(process.argv.splice(2)) 56 | ``` 57 | 58 | Acknowledgements 59 | ---------------- 60 | 61 | This project was kindly sponsored by [nearForm](http://nearform.com). 62 | 63 | License 64 | ------- 65 | 66 | MIT 67 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 5.x.x | :white_check_mark: | 8 | | 4.x.x | :x: | 9 | | 3.x.x | :x: | 10 | | 2.x.x | :x: | 11 | | 1.x.x | :x: | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | Please report all vulnerabilities via [https://github.com/mcollina/help-me/security](https://github.com/mcollina/help-me/security). 16 | -------------------------------------------------------------------------------- /doc/hello.txt: -------------------------------------------------------------------------------- 1 | this is hello world 2 | -------------------------------------------------------------------------------- /doc/help.txt: -------------------------------------------------------------------------------- 1 | HELP-ME by Matteo 2 | 3 | * start starts a script 4 | * help shows help 5 | 6 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const commist = require('commist')() 5 | const help = require('./')({ 6 | dir: path.join(path.dirname(require.main.filename), 'doc') 7 | }) 8 | 9 | commist.register('help', help.toStdout) 10 | commist.register('start', function () { 11 | console.log('Starting the script!') 12 | }) 13 | 14 | const res = commist.parse(process.argv.splice(2)) 15 | 16 | if (res) { 17 | help.toStdout() 18 | } 19 | -------------------------------------------------------------------------------- /fixture/basic/hello.txt: -------------------------------------------------------------------------------- 1 | ahdsadhdash 2 | -------------------------------------------------------------------------------- /fixture/basic/help.txt: -------------------------------------------------------------------------------- 1 | hello world 2 | -------------------------------------------------------------------------------- /fixture/dir/a/b.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcollina/help-me/6825d17a2d651230256266fe3e6671b713e92701/fixture/dir/a/b.txt -------------------------------------------------------------------------------- /fixture/no-ext/hello: -------------------------------------------------------------------------------- 1 | ghghghhg 2 | -------------------------------------------------------------------------------- /fixture/sameprefix/hello world.txt: -------------------------------------------------------------------------------- 1 | hello world -------------------------------------------------------------------------------- /fixture/sameprefix/hello.txt: -------------------------------------------------------------------------------- 1 | hello -------------------------------------------------------------------------------- /fixture/shortnames/abcde fghi lmno.txt: -------------------------------------------------------------------------------- 1 | ewweqjewqjewqj 2 | -------------------------------------------------------------------------------- /fixture/shortnames/abcde hello.txt: -------------------------------------------------------------------------------- 1 | 45678 2 | -------------------------------------------------------------------------------- /fixture/shortnames/hello world.txt: -------------------------------------------------------------------------------- 1 | 12345 2 | -------------------------------------------------------------------------------- /help-me.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fs = require('fs') 4 | const { PassThrough, Writable, pipeline } = require('stream') 5 | const process = require('process') 6 | const { join } = require('path') 7 | 8 | const defaults = { 9 | ext: '.txt', 10 | help: 'help' 11 | } 12 | 13 | function isDirectory (path) { 14 | try { 15 | const stat = fs.lstatSync(path) 16 | return stat.isDirectory() 17 | } catch (err) { 18 | return false 19 | } 20 | } 21 | 22 | function createDefaultStream () { 23 | return new Writable({ 24 | write (chunk, encoding, callback) { 25 | process.stdout.write(chunk, callback) 26 | } 27 | }) 28 | } 29 | 30 | function helpMe (opts) { 31 | opts = Object.assign({}, defaults, opts) 32 | 33 | if (!opts.dir) { 34 | throw new Error('missing dir') 35 | } 36 | 37 | if (!isDirectory(opts.dir)) { 38 | throw new Error(`${opts.dir} is not a directory`) 39 | } 40 | 41 | return { 42 | createStream: createStream, 43 | toStdout: toStdout 44 | } 45 | 46 | function createStream (args) { 47 | if (typeof args === 'string') { 48 | args = args.split(' ') 49 | } else if (!args || args.length === 0) { 50 | args = [opts.help] 51 | } 52 | 53 | const out = new PassThrough() 54 | const re = new RegExp( 55 | args 56 | .map(function (arg) { 57 | return arg + '[a-zA-Z0-9]*' 58 | }) 59 | .join('[ /]+') 60 | ) 61 | 62 | if (process.platform === 'win32') { 63 | opts.dir = opts.dir.split('\\').join('/') 64 | } 65 | 66 | fs.readdir(opts.dir, function (err, files) { 67 | if (err) return out.emit('error', err) 68 | 69 | const regexp = new RegExp('.*' + opts.ext + '$') 70 | files = files 71 | .filter(function (file) { 72 | const matched = file.match(regexp) 73 | return !!matched 74 | }) 75 | .map(function (relative) { 76 | return { file: join(opts.dir, relative), relative } 77 | }) 78 | .filter(function (file) { 79 | return file.relative.match(re) 80 | }) 81 | 82 | if (files.length === 0) { 83 | return out.emit('error', new Error('no such help file')) 84 | } else if (files.length > 1) { 85 | const exactMatch = files.find( 86 | (file) => file.relative === `${args[0]}${opts.ext}` 87 | ) 88 | if (!exactMatch) { 89 | out.write('There are ' + files.length + ' help pages ') 90 | out.write('that matches the given request, please disambiguate:\n') 91 | files.forEach(function (file) { 92 | out.write(' * ') 93 | out.write(file.relative.replace(opts.ext, '')) 94 | out.write('\n') 95 | }) 96 | out.end() 97 | return 98 | } 99 | files = [exactMatch] 100 | } 101 | 102 | pipeline(fs.createReadStream(files[0].file), out, () => {}) 103 | }) 104 | 105 | return out 106 | } 107 | 108 | function toStdout (args = [], opts) { 109 | opts = opts || {} 110 | const stream = opts.stream || createDefaultStream() 111 | const _onMissingHelp = opts.onMissingHelp || onMissingHelp 112 | return new Promise((resolve, reject) => { 113 | createStream(args) 114 | .on('error', (err) => { 115 | _onMissingHelp(err, args, stream).then(resolve, reject) 116 | }) 117 | .pipe(stream) 118 | .on('close', resolve) 119 | .on('end', resolve) 120 | }) 121 | } 122 | 123 | function onMissingHelp (_, args, stream) { 124 | stream.write(`no such help file: ${args.join(' ')}.\n\n`) 125 | return toStdout([], { stream, async onMissingHelp () {} }) 126 | } 127 | } 128 | 129 | function help (opts, args) { 130 | return helpMe(opts).toStdout(args, opts) 131 | } 132 | 133 | module.exports = helpMe 134 | module.exports.help = help 135 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "help-me", 3 | "version": "5.0.0", 4 | "description": "Help command for node, partner of minimist and commist", 5 | "main": "help-me.js", 6 | "scripts": { 7 | "test": "standard && node test.js | tap-spec" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/mcollina/help-me.git" 12 | }, 13 | "keywords": [ 14 | "help", 15 | "command", 16 | "minimist", 17 | "commist" 18 | ], 19 | "author": "Matteo Collina ", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/mcollina/help-me/issues" 23 | }, 24 | "homepage": "https://github.com/mcollina/help-me", 25 | "devDependencies": { 26 | "commist": "^2.0.0", 27 | "concat-stream": "^2.0.0", 28 | "pre-commit": "^1.1.3", 29 | "proxyquire": "^2.1.3", 30 | "standard": "^16.0.0", 31 | "tap-spec": "^5.0.0", 32 | "tape": "^5.0.0" 33 | }, 34 | "dependencies": { 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('tape') 4 | const concat = require('concat-stream') 5 | const fs = require('fs') 6 | const os = require('os') 7 | const path = require('path') 8 | const helpMe = require('./') 9 | const proxyquire = require('proxyquire') 10 | 11 | test('throws if no directory is passed', function (t) { 12 | try { 13 | helpMe() 14 | t.fail() 15 | } catch (err) { 16 | t.equal(err.message, 'missing dir') 17 | } 18 | t.end() 19 | }) 20 | 21 | test('throws if a normal file is passed', function (t) { 22 | try { 23 | helpMe({ 24 | dir: __filename 25 | }) 26 | t.fail() 27 | } catch (err) { 28 | t.equal(err.message, `${__filename} is not a directory`) 29 | } 30 | t.end() 31 | }) 32 | 33 | test('throws if the directory cannot be accessed', function (t) { 34 | try { 35 | helpMe({ 36 | dir: './foo' 37 | }) 38 | t.fail() 39 | } catch (err) { 40 | t.equal(err.message, './foo is not a directory') 41 | } 42 | t.end() 43 | }) 44 | 45 | test('show a generic help.txt from a folder to a stream with relative path in dir', function (t) { 46 | t.plan(2) 47 | 48 | helpMe({ 49 | dir: 'fixture/basic' 50 | }).createStream() 51 | .pipe(concat(function (data) { 52 | fs.readFile('fixture/basic/help.txt', function (err, expected) { 53 | t.error(err) 54 | t.equal(data.toString(), expected.toString()) 55 | }) 56 | })) 57 | }) 58 | 59 | test('show a generic help.txt from a folder to a stream with absolute path in dir', function (t) { 60 | t.plan(2) 61 | 62 | helpMe({ 63 | dir: path.join(__dirname, 'fixture/basic') 64 | }).createStream() 65 | .pipe(concat(function (data) { 66 | fs.readFile('fixture/basic/help.txt', function (err, expected) { 67 | t.error(err) 68 | t.equal(data.toString(), expected.toString()) 69 | }) 70 | })) 71 | }) 72 | 73 | test('custom help command with an array', function (t) { 74 | t.plan(2) 75 | 76 | helpMe({ 77 | dir: 'fixture/basic' 78 | }).createStream(['hello']) 79 | .pipe(concat(function (data) { 80 | fs.readFile('fixture/basic/hello.txt', function (err, expected) { 81 | t.error(err) 82 | t.equal(data.toString(), expected.toString()) 83 | }) 84 | })) 85 | }) 86 | 87 | test('custom help command without an ext', function (t) { 88 | t.plan(2) 89 | 90 | helpMe({ 91 | dir: 'fixture/no-ext', 92 | ext: '' 93 | }).createStream(['hello']) 94 | .pipe(concat(function (data) { 95 | fs.readFile('fixture/no-ext/hello', function (err, expected) { 96 | t.error(err) 97 | t.equal(data.toString(), expected.toString()) 98 | }) 99 | })) 100 | }) 101 | 102 | test('custom help command with a string', function (t) { 103 | t.plan(2) 104 | 105 | helpMe({ 106 | dir: 'fixture/basic' 107 | }).createStream('hello') 108 | .pipe(concat(function (data) { 109 | fs.readFile('fixture/basic/hello.txt', function (err, expected) { 110 | t.error(err) 111 | t.equal(data.toString(), expected.toString()) 112 | }) 113 | })) 114 | }) 115 | 116 | test('missing help file', function (t) { 117 | t.plan(1) 118 | 119 | helpMe({ 120 | dir: 'fixture/basic' 121 | }).createStream('abcde') 122 | .on('error', function (err) { 123 | t.equal(err.message, 'no such help file') 124 | }) 125 | .resume() 126 | }) 127 | 128 | test('custom help command with an array', function (t) { 129 | const helper = helpMe({ 130 | dir: 'fixture/shortnames' 131 | }) 132 | 133 | t.test('abbreviates two words in one', function (t) { 134 | t.plan(2) 135 | 136 | helper 137 | .createStream(['world']) 138 | .pipe(concat(function (data) { 139 | fs.readFile('fixture/shortnames/hello world.txt', function (err, expected) { 140 | t.error(err) 141 | t.equal(data.toString(), expected.toString()) 142 | }) 143 | })) 144 | }) 145 | 146 | t.test('abbreviates three words in two', function (t) { 147 | t.plan(2) 148 | 149 | helper 150 | .createStream(['abcde', 'fghi']) 151 | .pipe(concat(function (data) { 152 | fs.readFile('fixture/shortnames/abcde fghi lmno.txt', function (err, expected) { 153 | t.error(err) 154 | t.equal(data.toString(), expected.toString()) 155 | }) 156 | })) 157 | }) 158 | 159 | t.test('abbreviates a word', function (t) { 160 | t.plan(2) 161 | 162 | helper 163 | .createStream(['abc', 'fg']) 164 | .pipe(concat(function (data) { 165 | fs.readFile('fixture/shortnames/abcde fghi lmno.txt', function (err, expected) { 166 | t.error(err) 167 | t.equal(data.toString(), expected.toString()) 168 | }) 169 | })) 170 | }) 171 | 172 | t.test('abbreviates a word using strings', function (t) { 173 | t.plan(2) 174 | 175 | helper 176 | .createStream('abc fg') 177 | .pipe(concat(function (data) { 178 | fs.readFile('fixture/shortnames/abcde fghi lmno.txt', function (err, expected) { 179 | t.error(err) 180 | t.equal(data.toString(), expected.toString()) 181 | }) 182 | })) 183 | }) 184 | 185 | t.test('print a disambiguation', function (t) { 186 | t.plan(1) 187 | 188 | const expected = '' + 189 | 'There are 2 help pages that matches the given request, please disambiguate:\n' + 190 | ' * abcde fghi lmno\n' + 191 | ' * abcde hello\n' 192 | 193 | helper 194 | .createStream(['abc']) 195 | .pipe(concat({ encoding: 'string' }, function (data) { 196 | t.equal(data, expected) 197 | })) 198 | }) 199 | 200 | t.test('choose exact match over partial', function (t) { 201 | t.plan(1) 202 | 203 | helpMe({ 204 | dir: 'fixture/sameprefix' 205 | }).createStream(['hello']) 206 | .pipe(concat({ encoding: 'string' }, function (data) { 207 | t.equal(data, 'hello') 208 | })) 209 | }) 210 | }) 211 | 212 | test('toStdout helper', async function (t) { 213 | t.plan(2) 214 | 215 | let completed = false 216 | const stream = concat(function (data) { 217 | completed = true 218 | fs.readFile('fixture/basic/help.txt', function (err, expected) { 219 | t.error(err) 220 | t.equal(data.toString(), expected.toString()) 221 | }) 222 | }) 223 | 224 | await helpMe({ 225 | dir: 'fixture/basic' 226 | }).toStdout([], { stream }) 227 | 228 | t.ok(completed) 229 | }) 230 | 231 | test('handle error in toStdout', async function (t) { 232 | t.plan(2) 233 | 234 | let completed = false 235 | const stream = concat(function (data) { 236 | completed = true 237 | fs.readFile('fixture/basic/help.txt', function (err, expected) { 238 | t.error(err) 239 | t.equal(data.toString(), 'no such help file: something.\n\n' + expected.toString()) 240 | }) 241 | }) 242 | 243 | await helpMe({ 244 | dir: 'fixture/basic' 245 | }).toStdout(['something'], { 246 | stream 247 | }) 248 | 249 | t.ok(completed) 250 | }) 251 | 252 | test('customize missing help fle message', async function (t) { 253 | t.plan(3) 254 | 255 | const stream = concat(function (data) { 256 | t.equal(data.toString(), 'kaboom\n\n') 257 | }) 258 | 259 | await helpMe({ 260 | dir: 'fixture/basic' 261 | }).toStdout(['something'], { 262 | stream, 263 | async onMissingHelp (err, args, stream) { 264 | t.equal(err.message, 'no such help file') 265 | t.deepEquals(args, ['something']) 266 | stream.end('kaboom\n\n') 267 | } 268 | }) 269 | }) 270 | 271 | test('toStdout without factory', async function (t) { 272 | t.plan(2) 273 | 274 | let completed = false 275 | const stream = concat(function (data) { 276 | completed = true 277 | fs.readFile('fixture/basic/help.txt', function (err, expected) { 278 | t.error(err) 279 | t.equal(data.toString(), expected.toString()) 280 | }) 281 | }) 282 | 283 | await helpMe.help({ 284 | dir: 'fixture/basic', 285 | stream 286 | }, []) 287 | 288 | t.ok(completed) 289 | }) 290 | 291 | test('should allow for awaiting the response with default stdout stream', async function (t) { 292 | t.plan(2) 293 | 294 | const _process = Object.create(process) 295 | const stdout = Object.create(process.stdout) 296 | Object.defineProperty(_process, 'stdout', { 297 | value: stdout 298 | }) 299 | 300 | let completed = false 301 | stdout.write = (data, cb) => { 302 | t.equal(data.toString(), 'hello world' + os.EOL) 303 | completed = true 304 | cb() 305 | } 306 | 307 | const helpMe = proxyquire('./help-me', { 308 | process: _process 309 | }) 310 | 311 | await helpMe.help({ 312 | dir: 'fixture/basic' 313 | }) 314 | 315 | t.ok(completed) 316 | }) 317 | --------------------------------------------------------------------------------