├── test ├── fixtures │ ├── echo.sh │ ├── execFile.sh │ ├── no_execute_perm.sh │ ├── echoStderr.sh │ ├── echo2.sh │ ├── echostd.sh │ ├── touch.sh │ ├── forkstd.js │ ├── sleep10sec.sh │ ├── forkError.js │ ├── echo.ts │ ├── stdoutMultiple.sh │ ├── fork3.js │ ├── forkTouch.js │ └── forkTouch2.js ├── integration.spec.ts ├── test-util.ts ├── util.spec.ts ├── execFile.spec.ts ├── spawn.spec.ts ├── exec.spec.ts ├── fork.spec.ts └── operators.spec.ts ├── .husky ├── pre-commit └── commit-msg ├── commitlint.config.js ├── tsconfig.build.json ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── tsconfig.json ├── .eslintrc ├── src ├── index.ts ├── models.ts ├── execFile.ts ├── exec.ts ├── fork.ts ├── spawn.ts ├── util.ts └── operators.ts ├── .github └── workflows │ └── pull_request.yml ├── LICENSE ├── .gitignore ├── package.json ├── CHANGELOG.md └── README.md /test/fixtures/echo.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo Hello World 4 | -------------------------------------------------------------------------------- /test/fixtures/execFile.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | mkdir test 4 | -------------------------------------------------------------------------------- /test/fixtures/no_execute_perm.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | echo 'NO PERM' -------------------------------------------------------------------------------- /test/fixtures/echoStderr.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | >&2 echo "ERR" 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {extends: ['@commitlint/config-conventional']}; 2 | -------------------------------------------------------------------------------- /test/fixtures/echo2.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | delay 0.05 4 | 5 | echo Hello World2 6 | -------------------------------------------------------------------------------- /test/fixtures/echostd.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo "HELLO" 4 | >&2 echo "WORLD" 5 | -------------------------------------------------------------------------------- /test/fixtures/touch.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | sleep 0.05 4 | 5 | touch touched.txt 6 | -------------------------------------------------------------------------------- /test/fixtures/forkstd.js: -------------------------------------------------------------------------------- 1 | process.stdout.write('HELLO'); 2 | process.stderr.write('WORLD'); 3 | -------------------------------------------------------------------------------- /test/fixtures/sleep10sec.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | sleep 10 4 | touch sleep10sec_result.txt 5 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /test/fixtures/forkError.js: -------------------------------------------------------------------------------- 1 | var execSync = require('child_process').execSync; 2 | 3 | execSync('mkdir test'); 4 | -------------------------------------------------------------------------------- /test/fixtures/echo.ts: -------------------------------------------------------------------------------- 1 | console.log('hello world'); 2 | 3 | if (process.send) { 4 | process.send('good'); 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/stdoutMultiple.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo HELLO 4 | 5 | sleep 0.05 6 | 7 | echo WORLD 8 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*.ts"], 4 | "exclude": ["test"] 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/fork3.js: -------------------------------------------------------------------------------- 1 | process.on('message', function (msg) { 2 | process.send(msg + ' world'); 3 | process.exit(); 4 | }); 5 | -------------------------------------------------------------------------------- /test/fixtures/forkTouch.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | 3 | setTimeout(() => { 4 | fs.writeFileSync('./forkTouched.txt', 'hello world'); 5 | }, 50); 6 | -------------------------------------------------------------------------------- /test/fixtures/forkTouch2.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | 3 | setTimeout(() => { 4 | fs.writeFileSync('./forkTouched2.txt', 'hello world'); 5 | }, 50); 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | .nyc_output/ 3 | coverage/ 4 | dist/ 5 | yarn.lock 6 | .gitignore 7 | .prettierignore 8 | LICENSE 9 | CHANGELOG.md 10 | *.sh 11 | *.d.ts -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "printWidth": 80, 4 | "singleQuote": true, 5 | "trailingComma": "es5", 6 | "arrowParens": "avoid", 7 | "bracketSpacing": false 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.formatOnSave": true, 5 | "deno.enable": false 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "outDir": "./dist", 5 | "module": "commonjs", 6 | "declaration": true, 7 | "sourceMap": true, 8 | "strict": true, 9 | "typeRoots": ["node_modules/@types"], 10 | "esModuleInterop": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint"], 4 | "extends": ["plugin:@typescript-eslint/recommended"], 5 | "rules": { 6 | "@typescript-eslint/no-var-requires": 0, 7 | "@typescript-eslint/no-explicit-any": 0, 8 | "@typescript-eslint/explicit-module-boundary-types": 0 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './exec'; 2 | export * from './execFile'; 3 | export * from './fork'; 4 | export * from './spawn'; 5 | export * from './models'; 6 | export { 7 | trim, 8 | throwIf, 9 | throwIfStdout, 10 | throwIfStderr, 11 | execWithStdin, 12 | } from './operators'; 13 | export {spawnEnd, ShellError, listenTerminating} from './util'; 14 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: PR Build 2 | on: 3 | pull_request: 4 | branches: [ master ] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: build and test 11 | run: | 12 | npm install 13 | git log -1 --pretty=format:"%s" | yarn commitlint 14 | npm run build 15 | npm test 16 | -------------------------------------------------------------------------------- /src/models.ts: -------------------------------------------------------------------------------- 1 | export interface SpawnChunk { 2 | type: 'stdout' | 'stderr'; 3 | chunk: Buffer; 4 | } 5 | 6 | export function isSpawnChunk(obj: any): obj is SpawnChunk { 7 | return !!obj && typeof obj.type === 'string' && Buffer.isBuffer(obj.chunk); 8 | } 9 | 10 | export interface ExecOutput { 11 | stdout: string | Buffer; 12 | stderr: string | Buffer; 13 | } 14 | 15 | export function isExecOutput(obj: any): obj is ExecOutput { 16 | return ( 17 | !!obj && 18 | (Buffer.isBuffer(obj.stdout) || typeof obj.stdout === 'string') && 19 | (Buffer.isBuffer(obj.stderr) || typeof obj.stderr === 'string') 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /test/integration.spec.ts: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai'; 2 | 3 | import {throwIfStderr} from '../src'; 4 | import {exec} from '../src/exec'; 5 | 6 | describe('integration tests', () => { 7 | it('exec throwIfStderr', done => { 8 | exec('sh test/fixtures/echostd.sh') 9 | .pipe(throwIfStderr(/WOR/)) 10 | .subscribe( 11 | () => { 12 | expect.fail('absolutly not'); 13 | done(); 14 | }, 15 | err => { 16 | expect(err.toString()).equal( 17 | 'Error: throwIf: stderr is matching /WOR/' 18 | ); 19 | done(); 20 | } 21 | ); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/test-util.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events'; 2 | 3 | import {SinonSandbox, createSandbox} from 'sinon'; 4 | 5 | export class MockProcessEvent { 6 | private emitter: EventEmitter; 7 | private sandbox: SinonSandbox; 8 | 9 | constructor() { 10 | this.emitter = new EventEmitter(); 11 | this.sandbox = createSandbox(); 12 | 13 | this.sandbox 14 | .stub(process, 'on') 15 | .callsFake((name: any, fn: any) => this.emitter.on(name, fn) as any); 16 | } 17 | 18 | emit(eventName: string) { 19 | this.emitter.emit(eventName); 20 | } 21 | 22 | destroy() { 23 | this.sandbox.restore(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/execFile.ts: -------------------------------------------------------------------------------- 1 | import {ExecFileOptions, execFile as nodeExecFile} from 'child_process'; 2 | 3 | import {Observable, Subscriber} from 'rxjs'; 4 | 5 | import {ExecOutput} from './models'; 6 | import {ShellError, killProc, listenTerminating} from './util'; 7 | 8 | export function execFile( 9 | file: string, 10 | args?: any[], 11 | options?: ExecFileOptions 12 | ) { 13 | return new Observable((subscriber: Subscriber) => { 14 | const proc = nodeExecFile( 15 | file, 16 | args ? args.map(String) : args, 17 | options, 18 | (err, stdout, stderr) => { 19 | if (err) { 20 | subscriber.error( 21 | new ShellError('process exited with an error', err, stdout, stderr) 22 | ); 23 | 24 | return; 25 | } 26 | 27 | subscriber.next({stdout, stderr}); 28 | subscriber.complete(); 29 | } 30 | ); 31 | 32 | const removeEvents = listenTerminating(() => subscriber.complete()); 33 | 34 | return () => { 35 | killProc(proc); 36 | removeEvents(); 37 | }; 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /src/exec.ts: -------------------------------------------------------------------------------- 1 | import {ChildProcess, ExecOptions, exec as nodeExec} from 'child_process'; 2 | 3 | import {Observable, Subscriber} from 'rxjs'; 4 | 5 | import {ExecOutput} from './models'; 6 | import {ShellError, killProc, listenTerminating} from './util'; 7 | 8 | export function exec( 9 | command: string, 10 | options?: ExecOptions, 11 | procCallback?: (proc: ChildProcess) => void 12 | ): Observable { 13 | return new Observable((subscriber: Subscriber) => { 14 | const proc = nodeExec(command, options, (err, stdout, stderr) => { 15 | if (err) { 16 | subscriber.error( 17 | new ShellError('process exited with an error', err, stdout, stderr) 18 | ); 19 | 20 | return; 21 | } 22 | 23 | subscriber.next({stdout, stderr}); 24 | subscriber.complete(); 25 | }); 26 | 27 | const removeEvents = listenTerminating(() => subscriber.complete()); 28 | 29 | if (procCallback) { 30 | procCallback(proc); 31 | } 32 | 33 | return () => { 34 | killProc(proc); 35 | removeEvents(); 36 | }; 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 MinHyeong Kim 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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # Webstorm project directory 64 | /.idea 65 | 66 | # Build output directory 67 | dist/ 68 | -------------------------------------------------------------------------------- /test/util.spec.ts: -------------------------------------------------------------------------------- 1 | import {join} from 'path'; 2 | 3 | import {expect} from 'chai'; 4 | 5 | import {execFile} from '../src/execFile'; 6 | import {isExecOutput} from '../src/models'; 7 | import {spawn} from '../src/spawn'; 8 | import {ShellError, spawnEnd} from '../src/util'; 9 | 10 | describe('util.ts', () => { 11 | it('should continue stream when spawn stream completed', done => { 12 | spawnEnd( 13 | spawn('sh', [join(process.cwd(), 'test/fixtures/stdoutMultiple.sh')]) 14 | ).subscribe(() => done()); 15 | }); 16 | 17 | it("should emit `ExecOutput` type data that child process's output", done => { 18 | spawnEnd( 19 | spawn('sh', [join(process.cwd(), 'test/fixtures/echostd.sh')]) 20 | ).subscribe(out => { 21 | expect(isExecOutput(out)).to.be.true; 22 | done(); 23 | }); 24 | }); 25 | 26 | it('should pass error', done => { 27 | spawnEnd(spawn('oijweroijweoirjweoirj')).subscribe({ 28 | error(err) { 29 | expect(err instanceof ShellError).to.be.true; 30 | done(); 31 | }, 32 | }); 33 | }); 34 | 35 | it('should display annotated error', done => { 36 | execFile(join(process.cwd(), 'test/fixtures/no_execute_perm.sh')).subscribe( 37 | { 38 | error(err) { 39 | expect(err instanceof ShellError).to.be.true; 40 | 41 | if (err instanceof ShellError) { 42 | expect(err.toAnnotatedString()).to.match(/\* ERROR \*/); 43 | expect(err.toAnnotatedString()).to.match(/"EACCES"/); 44 | } 45 | 46 | done(); 47 | }, 48 | } 49 | ); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/fork.ts: -------------------------------------------------------------------------------- 1 | import {ForkOptions, Serializable, fork as nodeFork} from 'child_process'; 2 | 3 | import {Observable, Subject, Subscriber, Subscription} from 'rxjs'; 4 | 5 | import {ShellError, killProc, listenTerminating} from './util'; 6 | 7 | export function fork( 8 | modulePath: string, 9 | args?: any[], 10 | options?: ForkOptions & {send?: Subject} 11 | ) { 12 | return new Observable((subscriber: Subscriber) => { 13 | const proc = nodeFork(modulePath, args ? args.map(String) : args, options); 14 | const channelSubscriptions: Subscription[] = []; 15 | 16 | if (!!options && options.send instanceof Subject) { 17 | channelSubscriptions.push(options.send.subscribe(msg => proc.send(msg))); 18 | } 19 | 20 | proc.on('message', msg => subscriber.next(msg)); 21 | 22 | proc.on('error', err => { 23 | process.exitCode = 1; 24 | 25 | subscriber.error(new ShellError('process exited with an error', err)); 26 | }); 27 | 28 | proc.on('close', (code, signal) => { 29 | channelSubscriptions.forEach(s => s.unsubscribe()); 30 | 31 | if (code !== 0) { 32 | process.exitCode = typeof code === 'number' ? code : undefined; 33 | 34 | subscriber.error( 35 | new ShellError(`process exited with code: ${code}`, { 36 | code, 37 | signal, 38 | }) 39 | ); 40 | 41 | return; 42 | } 43 | 44 | subscriber.complete(); 45 | }); 46 | 47 | const removeEvents = listenTerminating(() => subscriber.complete()); 48 | 49 | return () => { 50 | channelSubscriptions.forEach(s => s.unsubscribe()); 51 | 52 | killProc(proc); 53 | removeEvents(); 54 | }; 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /test/execFile.spec.ts: -------------------------------------------------------------------------------- 1 | import {existsSync} from 'fs'; 2 | import {join} from 'path'; 3 | 4 | import {expect} from 'chai'; 5 | import {sync as rimrafSync} from 'rimraf'; 6 | 7 | import {execFile} from '../src/execFile'; 8 | import {ShellError} from '../src/util'; 9 | import {MockProcessEvent} from './test-util'; 10 | 11 | describe('execFile.ts', () => { 12 | after(() => { 13 | rimrafSync(join(process.cwd(), 'touched.txt')); 14 | }); 15 | 16 | it('should return buffer text after script execution', done => { 17 | execFile(join(process.cwd(), 'test/fixtures/echo.sh')).subscribe( 18 | output => { 19 | expect(String(output.stdout).trim()).to.equal('Hello World'); 20 | done(); 21 | }, 22 | err => { 23 | if (err instanceof ShellError) { 24 | console.error(err.toAnnotatedString()); 25 | } 26 | } 27 | ); 28 | }); 29 | 30 | it('should kill process when stream unsubscribed', done => { 31 | const subs = execFile( 32 | join(process.cwd(), 'test/fixtures/touch.sh') 33 | ).subscribe(); 34 | 35 | subs.add(() => { 36 | expect(existsSync(join(process.cwd(), 'touched.txt'))).to.be.false; 37 | done(); 38 | }); 39 | 40 | subs.unsubscribe(); 41 | }); 42 | 43 | it('should handle error', done => { 44 | execFile(join(process.cwd(), 'test/fixtures/execFile.sh')).subscribe({ 45 | error(err) { 46 | expect(err instanceof ShellError).to.true; 47 | expect(String(err)).to.match(/error/i); 48 | done(); 49 | }, 50 | }); 51 | }); 52 | 53 | describe('should kill process when specific signals generated', () => { 54 | let mock: MockProcessEvent; 55 | 56 | beforeEach(() => (mock = new MockProcessEvent())); 57 | afterEach(() => mock.destroy()); 58 | 59 | it('SIGINT', done => { 60 | const subscription = execFile('test/fixtures/sleep10sec.sh').subscribe(); 61 | 62 | process.on('SIGINT', () => { 63 | expect(subscription.closed).is.true; 64 | done(); 65 | }); 66 | 67 | mock.emit('SIGINT'); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /test/spawn.spec.ts: -------------------------------------------------------------------------------- 1 | import {join} from 'path'; 2 | 3 | import {expect} from 'chai'; 4 | 5 | import {spawn} from '../src/spawn'; 6 | import {ShellError} from '../src/util'; 7 | import {MockProcessEvent} from './test-util'; 8 | 9 | describe('spawn.ts', () => { 10 | it('should return buffer text after script execution', done => { 11 | spawn('echo', ['hello world']).subscribe(output => { 12 | expect(String(output.chunk).trim()).to.equal('hello world'); 13 | done(); 14 | }); 15 | }); 16 | 17 | it('should should call procCallback', done => { 18 | spawn('cat', ['-'], undefined, proc => { 19 | proc.stdin.write('hello world'); 20 | proc.stdin.end(); 21 | }).subscribe(output => { 22 | expect(String(output.chunk).trim()).to.equal('hello world'); 23 | done(); 24 | }); 25 | }); 26 | 27 | it('should return stderr text.', done => { 28 | spawn('sh', [join(process.cwd(), 'test/fixtures/echoStderr.sh')]).subscribe( 29 | output => { 30 | expect(output.type).to.equal('stderr'); 31 | expect(String(output.chunk).trim()).to.equal('ERR'); 32 | done(); 33 | } 34 | ); 35 | }); 36 | 37 | it('should handle errors', done => { 38 | spawn('mkdir test').subscribe({ 39 | error(err) { 40 | expect(String(err)).to.match(/error/i); 41 | done(); 42 | }, 43 | }); 44 | }); 45 | 46 | it('should handle child_process.spawn errors', done => { 47 | spawn('not_exist').subscribe({ 48 | error(err) { 49 | expect(err instanceof ShellError).to.true; 50 | done(); 51 | }, 52 | }); 53 | }); 54 | 55 | describe('should kill process when specific signals generated', () => { 56 | let mock: MockProcessEvent; 57 | 58 | beforeEach(() => (mock = new MockProcessEvent())); 59 | afterEach(() => mock.destroy()); 60 | 61 | it('SIGINT', done => { 62 | const subscription = spawn('test/fixtures/sleep10sec.sh').subscribe(); 63 | 64 | process.on('SIGINT', () => { 65 | expect(subscription.closed).is.true; 66 | done(); 67 | }); 68 | 69 | mock.emit('SIGINT'); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /test/exec.spec.ts: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai'; 2 | import {switchMap} from 'rxjs/operators'; 3 | 4 | import {exec} from '../src/exec'; 5 | import {ShellError} from '../src/util'; 6 | import {MockProcessEvent} from './test-util'; 7 | 8 | describe('exec.ts', () => { 9 | it('should return buffer text after script execution', done => { 10 | exec('echo "Hello World"').subscribe(output => { 11 | expect(String(output.stdout).trim()).to.equal('Hello World'); 12 | done(); 13 | }); 14 | }); 15 | 16 | it('should call procCallback with ChildProcess object', done => { 17 | exec('cat -', undefined, proc => { 18 | proc.stdin?.write('Hello World'); 19 | proc.stdin?.end(); 20 | }).subscribe(output => { 21 | expect(String(output.stdout).trim()).to.equal('Hello World'); 22 | done(); 23 | }); 24 | }); 25 | 26 | it('should return stderr text.', done => { 27 | exec('>&2 echo "ERR"').subscribe(output => { 28 | expect(String(output.stderr).trim()).to.equal('ERR'); 29 | done(); 30 | }); 31 | }); 32 | 33 | it('should handle errors', done => { 34 | exec('mkdir test').subscribe({ 35 | error(err) { 36 | expect(err instanceof ShellError).to.true; 37 | expect(String(err)).to.match(/error/i); 38 | done(); 39 | }, 40 | }); 41 | }); 42 | 43 | it('should kill process when stream completed', done => { 44 | exec('sh ./test/fixtures/echo2.sh') 45 | .pipe(switchMap(() => exec('sh ./test/fixtures/echo.sh'))) 46 | .subscribe(output => { 47 | expect(String(output.stdout).trim()).to.equal('Hello World'); 48 | done(); 49 | }); 50 | }); 51 | 52 | describe('should kill process when specific signals generated', () => { 53 | let mock: MockProcessEvent; 54 | 55 | beforeEach(() => (mock = new MockProcessEvent())); 56 | afterEach(() => mock.destroy()); 57 | 58 | it('SIGINT', done => { 59 | const subscription = exec('sh ./test/fixtures/sleep10sec.sh').subscribe(); 60 | 61 | process.on('SIGINT', () => { 62 | expect(subscription.closed).is.true; 63 | done(); 64 | }); 65 | 66 | mock.emit('SIGINT'); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/spawn.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChildProcessWithoutNullStreams, 3 | SpawnOptions, 4 | spawn as nodeSpawn, 5 | } from 'child_process'; 6 | 7 | import {Observable, Subscriber} from 'rxjs'; 8 | 9 | import {SpawnChunk} from './models'; 10 | import {ShellError, killProc, listenTerminating} from './util'; 11 | 12 | export function spawn( 13 | command: string, 14 | args?: any[], 15 | options?: SpawnOptions, 16 | procCallback?: (proc: ChildProcessWithoutNullStreams) => void 17 | ) { 18 | return new Observable((subscriber: Subscriber) => { 19 | const proc = nodeSpawn( 20 | command, 21 | args ? args.map(String) : [], 22 | options as any 23 | ); 24 | const stdouts: Buffer[] = []; 25 | const stderrs: Buffer[] = []; 26 | 27 | if (proc.stdout) { 28 | proc.stdout.on('data', chunk => { 29 | stdouts.push(chunk); 30 | subscriber.next({type: 'stdout', chunk}); 31 | }); 32 | } 33 | 34 | if (proc.stderr) { 35 | proc.stderr.on('data', chunk => { 36 | stderrs.push(chunk); 37 | subscriber.next({type: 'stderr', chunk}); 38 | }); 39 | } 40 | 41 | proc.on('error', err => { 42 | process.exitCode = 1; 43 | 44 | subscriber.error( 45 | new ShellError( 46 | 'process exited with an error', 47 | err, 48 | Buffer.concat(stdouts), 49 | Buffer.concat(stderrs) 50 | ) 51 | ); 52 | }); 53 | 54 | proc.on('close', (code: number, signal: NodeJS.Signals) => { 55 | if (code > 0) { 56 | process.exitCode = code; 57 | 58 | subscriber.error( 59 | new ShellError( 60 | `process exited with code ${code}`, 61 | {code, signal}, 62 | Buffer.concat(stdouts), 63 | Buffer.concat(stderrs) 64 | ) 65 | ); 66 | 67 | return; 68 | } 69 | 70 | subscriber.complete(); 71 | }); 72 | 73 | const removeEvents = listenTerminating(() => subscriber.complete()); 74 | 75 | if (procCallback) { 76 | procCallback(proc); 77 | } 78 | 79 | return () => { 80 | killProc(proc); 81 | removeEvents(); 82 | }; 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /test/fork.spec.ts: -------------------------------------------------------------------------------- 1 | import {existsSync} from 'fs'; 2 | import {join} from 'path'; 3 | 4 | import {expect} from 'chai'; 5 | import {sync as rimrafSync} from 'rimraf'; 6 | import {Subject} from 'rxjs'; 7 | 8 | import {fork} from '../src/fork'; 9 | import {ShellError} from '../src/util'; 10 | import {MockProcessEvent} from './test-util'; 11 | 12 | describe('fork.ts', () => { 13 | after(() => { 14 | rimrafSync(join(process.cwd(), 'forkTouched.txt')); 15 | rimrafSync(join(process.cwd(), 'forkTouched2.txt')); 16 | }); 17 | 18 | it('should execute module', done => { 19 | fork(join(process.cwd(), 'test/fixtures/forkTouch.js')).subscribe({ 20 | complete() { 21 | expect(existsSync(join(process.cwd(), 'forkTouched.txt'))).to.be.true; 22 | done(); 23 | }, 24 | }); 25 | }); 26 | 27 | it('should kill process when stream unsubscribed', done => { 28 | const subs = fork( 29 | join(process.cwd(), 'test/fixtures/forkTouc2.js') 30 | ).subscribe(); 31 | 32 | subs.add(() => { 33 | expect(existsSync(join(process.cwd(), 'forkTouched2.txt'))).to.be.false; 34 | done(); 35 | }); 36 | 37 | subs.unsubscribe(); 38 | }); 39 | 40 | it('should send and receive message to forked process', done => { 41 | const send = new Subject(); 42 | 43 | fork(join(process.cwd(), 'test/fixtures/fork3.js'), undefined, { 44 | send, 45 | }).subscribe(msg => { 46 | expect(msg).to.equal('hello world'); 47 | done(); 48 | }); 49 | 50 | send.next('hello'); 51 | }); 52 | 53 | it('should fork ts module', done => { 54 | fork(join(process.cwd(), 'test/fixtures/echo.ts'), undefined).subscribe( 55 | () => done() 56 | ); 57 | }); 58 | 59 | it('should handle errors', done => { 60 | fork(join(process.cwd(), 'test/fixtures/forkError.js')).subscribe({ 61 | error(err) { 62 | expect(err instanceof ShellError).to.true; 63 | done(); 64 | }, 65 | }); 66 | }); 67 | 68 | describe('should kill process when specific signals generated', () => { 69 | let mock: MockProcessEvent; 70 | 71 | beforeEach(() => (mock = new MockProcessEvent())); 72 | afterEach(() => mock.destroy()); 73 | 74 | it('SIGINT', done => { 75 | const subscription = fork('test/fixtures/sleep10sec.sh').subscribe(); 76 | 77 | process.on('SIGINT', () => { 78 | expect(subscription.closed).is.true; 79 | done(); 80 | }); 81 | 82 | mock.emit('SIGINT'); 83 | }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import {ChildProcess} from 'child_process'; 2 | 3 | import {Observable, Subject} from 'rxjs'; 4 | import kill from 'tree-kill'; 5 | 6 | import {ExecOutput, SpawnChunk} from './models'; 7 | 8 | export function killProc(proc: ChildProcess) { 9 | if (proc.stdout) { 10 | proc.stdout.removeAllListeners(); 11 | } 12 | 13 | if (proc.stderr) { 14 | proc.stderr.removeAllListeners(); 15 | } 16 | 17 | proc.removeAllListeners(); 18 | 19 | if (typeof proc.pid === 'number') { 20 | kill(proc.pid, 'SIGKILL'); 21 | 22 | return; 23 | } 24 | 25 | proc.kill('SIGKILL'); 26 | } 27 | 28 | export function spawnEnd(spawnObservable: Observable) { 29 | const sbj = new Subject(); 30 | 31 | const stdouts: Buffer[] = []; 32 | const stderrs: Buffer[] = []; 33 | 34 | spawnObservable.subscribe( 35 | chunk => { 36 | if (chunk.type === 'stdout') { 37 | stdouts.push(chunk.chunk); 38 | } else { 39 | stderrs.push(chunk.chunk); 40 | } 41 | }, 42 | err => sbj.error(err), 43 | () => 44 | sbj.next({stdout: Buffer.concat(stdouts), stderr: Buffer.concat(stderrs)}) 45 | ); 46 | 47 | return sbj; 48 | } 49 | 50 | export class ShellError extends Error { 51 | constructor( 52 | public message: string, 53 | public originError?: any, 54 | public stdout?: string | Buffer, 55 | public stderr?: string | Buffer 56 | ) { 57 | super(message); 58 | } 59 | 60 | toAnnotatedString() { 61 | let msg = ` 62 | -----* MESSAGE *----- 63 | ${this.message} 64 | ---------------------`; 65 | 66 | if (this.originError) { 67 | msg += ` 68 | -----* ERROR *------- 69 | ${JSON.stringify(this.originError, undefined, 2)} 70 | ---------------------`; 71 | } 72 | 73 | if (this.stdout) { 74 | msg += ` 75 | -----* STDOUT *------ 76 | ${this.stdout.toString('utf8')} 77 | ---------------------`; 78 | } 79 | 80 | if (this.stderr) { 81 | msg += ` 82 | -----* STDERR *------ 83 | ${this.stderr.toString('utf8')} 84 | ---------------------`; 85 | } 86 | 87 | return msg; 88 | } 89 | } 90 | 91 | export function listenTerminating( 92 | fn: (signal: number) => any, 93 | events: NodeJS.Signals[] = ['SIGINT', 'SIGBREAK'] 94 | ): () => void { 95 | events.forEach(name => process.on(name, fn)); 96 | process.on('exit', fn); 97 | 98 | return () => { 99 | events.forEach(name => process.off(name, fn)); 100 | process.off('exit', fn); 101 | }; 102 | } 103 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rxjs-shell", 3 | "version": "3.1.3", 4 | "description": "rxjs operators for execute shell command with ease", 5 | "keywords": [ 6 | "rxjs", 7 | "operator", 8 | "shell" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "git://github.com/johnny-mh/rxjs-shell.git" 13 | }, 14 | "license": "MIT", 15 | "author": "johnny-mh ", 16 | "main": "dist/index.js", 17 | "types": "dist/index.d.ts", 18 | "files": [ 19 | "dist" 20 | ], 21 | "scripts": { 22 | "build": "rimraf dist && tsc -p tsconfig.build.json", 23 | "cover": "rimraf .nyc_output && rm -rf coverage && nyc mocha -r ts-node/register -r source-map-support/register test/**/*.spec.ts", 24 | "format": "prettier --write \"**/*.{ts,js,json,html,css}\"", 25 | "lint": "eslint \"**/*.{ts,js}\" && prettier --check \"**/*.{ts,js,json,html,css}\"", 26 | "test": "mocha -r ts-node/register test/**/*.spec.ts", 27 | "prepare": "husky install" 28 | }, 29 | "lint-staged": { 30 | "*.{ts,js}": "eslint", 31 | "*.{ts,js,json,html,css}": "prettier --check" 32 | }, 33 | "config": { 34 | "commitizen": { 35 | "path": "./node_modules/cz-conventional-changelog" 36 | } 37 | }, 38 | "nyc": { 39 | "all": true, 40 | "exclude": [ 41 | "dist/", 42 | "coverage", 43 | "*.config.js", 44 | "src/index.ts", 45 | "**/*.spec.ts", 46 | "**/flycheck**", 47 | "test/fixtures/**" 48 | ], 49 | "extension": [ 50 | ".ts" 51 | ], 52 | "reporter": [ 53 | "text", 54 | "html" 55 | ] 56 | }, 57 | "dependencies": { 58 | "rxjs": "^7.4.0", 59 | "tree-kill": "^1.2.2" 60 | }, 61 | "devDependencies": { 62 | "@commitlint/cli": "^17.6.1", 63 | "@commitlint/config-conventional": "^15.0.0", 64 | "@types/chai": "^4.3.0", 65 | "@types/chai-spies": "^1.0.3", 66 | "@types/mkdirp": "^1.0.2", 67 | "@types/mocha": "^9.0.0", 68 | "@types/node": "^16.11.12", 69 | "@types/rimraf": "^3.0.2", 70 | "@types/sinon": "^10.0.6", 71 | "@typescript-eslint/eslint-plugin": "^5.6.0", 72 | "@typescript-eslint/parser": "^5.6.0", 73 | "chai": "^4.3.4", 74 | "chai-exclude": "^2.1.0", 75 | "chai-spies": "^1.0.0", 76 | "cz-conventional-changelog": "^3.3.0", 77 | "eslint": "^8.4.1", 78 | "husky": "^7.0.0", 79 | "import-sort-style-module": "^6.0.0", 80 | "lint-staged": "^13.2.1", 81 | "mkdirp": "^1.0.4", 82 | "mocha": "^9.1.3", 83 | "nyc": "^15.1.0", 84 | "prettier": "^2.5.1", 85 | "prettier-plugin-import-sort": "^0.0.7", 86 | "rimraf": "^3.0.2", 87 | "sinon": "^12.0.1", 88 | "source-map-support": "^0.5.21", 89 | "standard-version": "^9.3.2", 90 | "ts-node": "^10.4.0", 91 | "typescript": "^4.5.3" 92 | }, 93 | "importSort": { 94 | ".js, .jsx, .ts, .tsx": { 95 | "style": "module", 96 | "parser": "typescript" 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/operators.ts: -------------------------------------------------------------------------------- 1 | import {ExecOptions} from 'child_process'; 2 | 3 | import {Observable, mergeMap} from 'rxjs'; 4 | 5 | import {exec} from './exec'; 6 | import {ExecOutput, isExecOutput, isSpawnChunk} from './models'; 7 | import {ShellError} from './util'; 8 | 9 | export function trim(encoding: BufferEncoding = 'utf8') { 10 | return function trimImplementation(source: Observable): Observable { 11 | return Observable.create((subscriber: any) => { 12 | const subscription = source.subscribe( 13 | value => { 14 | if (isSpawnChunk(value)) { 15 | subscriber.next({ 16 | type: value.type, 17 | chunk: Buffer.from(String(value.chunk).trim(), encoding), 18 | }); 19 | } else if (isExecOutput(value)) { 20 | subscriber.next({ 21 | stdout: Buffer.isBuffer(value.stdout) 22 | ? Buffer.from(value.stdout.toString(encoding).trim()) 23 | : value.stdout.trim(), 24 | stderr: Buffer.isBuffer(value.stderr) 25 | ? Buffer.from(value.stderr.toString(encoding).trim()) 26 | : value.stderr.trim(), 27 | }); 28 | } else { 29 | subscriber.next(value); 30 | } 31 | }, 32 | err => subscriber.error(err), 33 | () => subscriber.complete() 34 | ); 35 | 36 | return subscription; 37 | }); 38 | }; 39 | } 40 | 41 | enum TargetOutput { 42 | STDOUT = 1 << 0, 43 | STDERR = 1 << 1, 44 | } 45 | 46 | function throwIfNeeded( 47 | subscriber: any, 48 | value: any, 49 | pattern: RegExp, 50 | targetOutput: TargetOutput 51 | ) { 52 | if (isSpawnChunk(value)) { 53 | if ( 54 | targetOutput & TargetOutput.STDOUT && 55 | 'stdout' === value.type && 56 | pattern.test(value.chunk.toString()) 57 | ) { 58 | subscriber.error( 59 | new ShellError( 60 | `throwIf: stdout is matching ${pattern}`, 61 | undefined, 62 | value.chunk 63 | ) 64 | ); 65 | 66 | return; 67 | } 68 | 69 | if ( 70 | targetOutput & TargetOutput.STDERR && 71 | 'stderr' === value.type && 72 | pattern.test(value.chunk.toString()) 73 | ) { 74 | subscriber.error( 75 | new ShellError( 76 | `throwIf: stderr is matching ${pattern}`, 77 | undefined, 78 | undefined, 79 | value.chunk 80 | ) 81 | ); 82 | 83 | return; 84 | } 85 | 86 | subscriber.next(value); 87 | 88 | return; 89 | } else if (isExecOutput(value)) { 90 | if ( 91 | targetOutput & TargetOutput.STDOUT && 92 | pattern.test(value.stdout.toString()) 93 | ) { 94 | subscriber.error( 95 | new ShellError( 96 | `throwIf: stdout is matching ${pattern}`, 97 | undefined, 98 | value.stdout 99 | ) 100 | ); 101 | 102 | return; 103 | } 104 | 105 | if ( 106 | targetOutput & TargetOutput.STDERR && 107 | pattern.test(value.stderr.toString()) 108 | ) { 109 | subscriber.error( 110 | new ShellError( 111 | `throwIf: stderr is matching ${pattern}`, 112 | undefined, 113 | undefined, 114 | value.stderr 115 | ) 116 | ); 117 | 118 | return; 119 | } 120 | 121 | subscriber.next(value); 122 | 123 | return; 124 | } 125 | 126 | subscriber.next(value); 127 | } 128 | 129 | export function throwIf(pattern: string | RegExp) { 130 | const _pattern = 'string' === typeof pattern ? new RegExp(pattern) : pattern; 131 | 132 | return function throwIfImplementation(source: Observable): Observable { 133 | return Observable.create((subscriber: any) => { 134 | const subscription = source.subscribe( 135 | value => 136 | throwIfNeeded( 137 | subscriber, 138 | value, 139 | _pattern, 140 | TargetOutput.STDOUT | TargetOutput.STDERR 141 | ), 142 | err => subscriber.error(err), 143 | () => subscriber.complete() 144 | ); 145 | 146 | return subscription; 147 | }); 148 | }; 149 | } 150 | 151 | export function throwIfStdout(pattern: string | RegExp) { 152 | const _pattern = 'string' === typeof pattern ? new RegExp(pattern) : pattern; 153 | 154 | return function throwIfStdoutImplementation( 155 | source: Observable 156 | ): Observable { 157 | return Observable.create((subscriber: any) => { 158 | const subscription = source.subscribe( 159 | value => 160 | throwIfNeeded(subscriber, value, _pattern, TargetOutput.STDOUT), 161 | err => subscriber.error(err), 162 | () => subscriber.complete() 163 | ); 164 | 165 | return subscription; 166 | }); 167 | }; 168 | } 169 | 170 | export function throwIfStderr(pattern: string | RegExp) { 171 | const _pattern = 'string' === typeof pattern ? new RegExp(pattern) : pattern; 172 | 173 | return function throwIfStderrImplementation( 174 | source: Observable 175 | ): Observable { 176 | return Observable.create((subscriber: any) => { 177 | const subscription = source.subscribe( 178 | value => 179 | throwIfNeeded(subscriber, value, _pattern, TargetOutput.STDERR), 180 | err => subscriber.error(err), 181 | () => subscriber.complete() 182 | ); 183 | 184 | return subscription; 185 | }); 186 | }; 187 | } 188 | 189 | export function execWithStdin(command: string, options?: ExecOptions) { 190 | return function execWithStdinImplementation( 191 | source: Observable 192 | ): Observable { 193 | return source.pipe( 194 | mergeMap(input => 195 | exec(command, options, proc => { 196 | proc.stdin?.write(input); 197 | proc.stdin?.end(); 198 | }) 199 | ) 200 | ); 201 | }; 202 | } 203 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [3.1.3](https://github.com/johnny-mh/rxjs-shell/compare/v3.1.2...v3.1.3) (2023-01-08) 6 | 7 | ### [3.1.2](https://github.com/johnny-mh/rxjs-shell/compare/v3.1.1...v3.1.2) (2021-12-15) 8 | 9 | 10 | ### Bug Fixes 11 | 12 | * export newly added operator ([df84495](https://github.com/johnny-mh/rxjs-shell/commit/df844956f5f1541713641eb61944a17beaad59a5)) 13 | 14 | ### [3.1.1](https://github.com/johnny-mh/rxjs-shell/compare/v3.1.0...v3.1.1) (2021-12-15) 15 | 16 | ## [3.1.0](https://github.com/johnny-mh/rxjs-shell/compare/v3.0.8...v3.1.0) (2021-12-15) 17 | 18 | 19 | ### Features 20 | 21 | * add execWithStdin operator ([04cd51c](https://github.com/johnny-mh/rxjs-shell/commit/04cd51c5c20459f7ddc045fa3dfb1e57759ff551)) 22 | * add procCallback to spawn function ([04f01a2](https://github.com/johnny-mh/rxjs-shell/commit/04f01a2c39a8d88ba48481fefb9b971e15a07cb9)) 23 | 24 | ### [3.0.8](https://github.com/johnny-mh/rxjs-shell/compare/v3.0.7...v3.0.8) (2021-12-13) 25 | 26 | ### [3.0.7](https://github.com/johnny-mh/rxjs-shell/compare/v3.0.6...v3.0.7) (2021-05-29) 27 | 28 | ### [3.0.5](https://github.com/johnny-mh/rxjs-shell/compare/v3.0.4...v3.0.5) (2020-10-06) 29 | 30 | 31 | ### Bug Fixes 32 | 33 | * change type of ShellError~originError to solve wrong build issue ([#18](https://github.com/johnny-mh/rxjs-shell/issues/18)) ([34e2081](https://github.com/johnny-mh/rxjs-shell/commit/34e2081847905224adcab4a145b288fef09e5815)) 34 | 35 | ### [3.0.4](https://github.com/johnny-mh/rxjs-shell/compare/v3.0.3...v3.0.4) (2020-08-15) 36 | 37 | ### [3.0.3](https://github.com/johnny-mh/rxjs-shell/compare/v3.0.2...v3.0.3) (2020-07-14) 38 | 39 | 40 | ### Bug Fixes 41 | 42 | * bump standard-version from 8.0.0 to 8.0.1 ([50cc9c4](https://github.com/johnny-mh/rxjs-shell/commit/50cc9c426114933acd1b92ba8dbd22617a1b3aae)) 43 | 44 | ### [3.0.2](https://github.com/johnny-mh/rxjs-shell/compare/v3.0.1...v3.0.2) (2020-07-07) 45 | 46 | 47 | ### Bug Fixes 48 | 49 | * export interfaces and guards (`SpawnChunk`, `isSpawnChunk`, `ExecOutput`, `isExecOutput`) ([c058179](https://github.com/johnny-mh/rxjs-shell/commit/c0581795b55a97fa9aaf0a63408e993ea9984f73)) 50 | 51 | ### [3.0.1](https://github.com/johnny-mh/rxjs-shell/compare/v3.0.0...v3.0.1) (2020-07-06) 52 | 53 | 54 | ### Bug Fixes 55 | 56 | * using `SpawnOptions` instead of `SpawnOptionsWithoutStdio` ([70530fd](https://github.com/johnny-mh/rxjs-shell/commit/70530fd251ec212169a12bcacbefbd7d3559c04a)) 57 | 58 | ## [3.0.0](https://github.com/johnny-mh/rxjs-shell/compare/v2.1.2...v3.0.0) (2020-07-06) 59 | 60 | ### Features 61 | 62 | * chore: using yarn, eslint, commitlint, standard-version 63 | 64 | * feat: adding `toAnnotatedString` method to `ShellError` 65 | 66 | You can print annotated error message for debugging purpose. Guessing error is hard before. 67 | 68 | ### ⚠ BREAKING CHANGES 69 | 70 | * `ShellError` class property `code` removed. you can use `originError` 71 | 72 | ### [2.1.2](https://github.com/johnny-mh/rxjs-shell/compare/v2.0.0...v2.1.2) (2020-07-06) 73 | 74 | 75 | ### Features 76 | 77 | * exporting `listenTerminating` ([a74cab8](https://github.com/johnny-mh/rxjs-shell/commit/a74cab89a4395985c05bea8d0d499d4422699e44)) 78 | 79 | 80 | ### Bug Fixes 81 | 82 | * `listenTerminating` callback parameter type ([c78fc86](https://github.com/johnny-mh/rxjs-shell/commit/c78fc8685917c41f6126400a1fdfb9e4db523ca1)) 83 | * `listenTerminating` listen `SIGINT`, `SIGBREAK` only ([8f7d9b2](https://github.com/johnny-mh/rxjs-shell/commit/8f7d9b2e67fde15427ff951bf61b9c620cc8f12d)) 84 | 85 | # 2.1.2 (2020-06-11) 86 | 87 | - change `listenTerminating` callback parameter type 88 | 89 | # 2.1.1 (2020-06-11) 90 | 91 | - change `listenTerminating` listen `SIGINT`, `SIGBREAK` only. 92 | 93 | # 2.1.0 (2020-06-10) 94 | 95 | - export `listenTerminating` to terminating whole process 96 | 97 | # 2.0.0 (2020-04-08) 98 | 99 | - add `throwIf`, `throwIfStdout`, `throwIfStderr` operators to throw error manually ([#11](https://github.com/johnny-mh/rxjs-shell/issues/11)) 100 | 101 | ### Breaking Changes 102 | 103 | - `trim` keep `ExecOutput` properties type. if type of stdout is `Buffer` output type is `Buffer`. if `string` output is `string` 104 | 105 | # 1.0.4 (2020-03-26) 106 | 107 | - all methods cancel process when following signals and events generated. `SIGINT`, `SIGUSR1`, `SIGUSR2`, `SIGTERM`, `exit`, `uncaughtException` ([#9](https://github.com/johnny-mh/rxjs-shell/issues/9)) 108 | - change `fork`, `spawn` seconds parameter of methods for convenience. now accept `any[]`. casting internally 109 | - fix security vulnerabilities 110 | 111 | # 1.0.3 (2020-01-17) 112 | 113 | - fix security vulnerabilities 114 | 115 | # 1.0.2 (2019-09-04) 116 | 117 | - add error handling example 118 | 119 | # 1.0.1 (2019-09-02) 120 | 121 | - fix security vulnerabilities 122 | 123 | # 1.0.0 (2018-12-15) 124 | 125 | - no changes. just update version 126 | 127 | # 0.0.7 (2018-10-24) 128 | 129 | - enhance `ShellError` data 130 | - `spawnEnd` now emit `ExecOutput` of `spawn` results 131 | 132 | ### Breaking Changes 133 | 134 | - `spawn` emit `SpawnChunk` type. no `Buffer` 135 | - `exec`, `execFile` emit `ExecOutput` type. no `string` 136 | - `fork` emit child process's message. no `string | Buffer` 137 | - deleted `fork` method's `recv` option. messages from child process will emit subscription 138 | 139 | # 0.0.6 140 | 141 | - not use 142 | 143 | # 0.0.5 (2018-10-18) 144 | 145 | - create `ShellError` class for throw shell errors 146 | - `operators/print` operator deprecated. (use `{stdio: 'inherit'}` instead) 147 | 148 | # 0.0.4 (2018-10-13) 149 | 150 | - change function name `operators/printBuf` to `operators/print` 151 | 152 | # 0.0.3 (2018-10-13) 153 | 154 | - change package name to `rxjs-shell` 155 | 156 | ### Features 157 | 158 | - add `util/spawnEnd`: to know when `spawn` stream completed 159 | - add `operators/trim`: trim output buffer or string contents 160 | - add `operators/printBuf`: syntax sugar of `tap(buf => process.stdout.write(buf))` 161 | 162 | # [0.0.2](https://github.com/johnny-mh/rxjs-shell-operators/commit/d249d3570dcc6d87d200aae4570c621a90aafdeb) (2018-10-10) 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rxjs-shell 2 | 3 | [![PR Build](https://github.com/johnny-mh/rxjs-shell/actions/workflows/pull_request.yml/badge.svg)](https://github.com/johnny-mh/rxjs-shell/actions/workflows/pull_request.yml) 4 | 5 | rxjs operators for execute shell command with ease. 6 | 7 | ## Features 8 | 9 | - Wrap nodejs asynchronous process creation methods to rxjs Observable. 10 | - Kill child process when unsubscribed. 11 | - Use subject to communicate with child process. 12 | 13 | ## Functions 14 | 15 | ### exec(command[, options][, proccallback]) → Observable\<{stdout: string | Buffer, stderr: string | Buffer}\> 16 | 17 | - `options` interface is same with nodejs `exec` method 18 | - `procCallback` you can pass function. [ChildProcess](https://nodejs.org/dist/latest-v16.x/docs/api/child_process.html#class-childprocess) will be passed first argument. 19 | 20 | ```typescript 21 | import {exec} from 'rxjs-shell'; 22 | 23 | exec('echo Hello World').subscribe(output => { 24 | console.log(output.stdout.toString('utf8')); // Hello World\n 25 | }); 26 | 27 | 28 | // using `procCallback` 29 | exec('cat -', undefined, proc => { 30 | proc.stdin?.write('Hello World'); 31 | proc.stdin?.end(); // it may cause endless process if you don't handle right. 32 | }).subscribe(output => { /* ... */ }) 33 | ``` 34 | 35 | ### execFile(file[, args][, options]) → Observable\<{stdout: string | Buffer, stderr: string | Buffer}\> 36 | 37 | - `options` interface is same with nodejs `execFile` method 38 | 39 | ```typescript 40 | import {existSync} from 'fs'; 41 | import {execFile} from 'rxjs-shell'; 42 | execFile('./touchFile.sh').subscribe(() => { 43 | console.log(existSync('touched.txt')); // true 44 | }); 45 | ``` 46 | 47 | ### spawn(command[, args][, options][, procCallback]) → Observable\<{type: 'stdout' | 'stderr', chunk: Buffer}\> 48 | 49 | - `spawn` emits `stdout`, `stderr`'s buffer from command execution. 50 | - `options` interface is same with nodejs `spawn` method 51 | - `procCallback` you can pass function. `ChildProcessWithoutNullStreams` will be passed first argument. 52 | 53 | ```typescript 54 | import {spawn} from 'rxjs-shell'; 55 | 56 | spawn('git clone http://github.com/johnny-mh/rxjs-shell-operators') 57 | .pipe(tap(chunk => process.stdout.write(String(chunk.chunk)))) 58 | .subscribe(); 59 | 60 | // using `procCallback` 61 | spawn('cat', ['-'], undefined, proc => { 62 | proc.stdin.write('hello world'); 63 | proc.stdin.end(); // caution 64 | }).subscribe(output => { /* ... */ }); 65 | ``` 66 | 67 | ### fork(modulePath[, args][, options]) → Observable\ 68 | 69 | - same with `spawn` but have own `options` interface that extend nodejs's `fork` options to communicate with child process. 70 | 71 | ```typescript 72 | import {Subject} from 'rxjs'; 73 | import {fork} from 'rxjs-shell'; 74 | 75 | const send = new Subject(); 76 | 77 | fork('echo.js', undefined, {send}).subscribe(msgFromChildProc => 78 | console.log(msgFromChildProc) 79 | ); 80 | 81 | send.next('message to child process'); 82 | ``` 83 | 84 | ## Operators 85 | 86 | ### trim(encoding = 'utf8') 87 | 88 | - trim child process output 89 | 90 | ```typescript 91 | import {exec, trim} from 'rxjs-shell'; 92 | 93 | exec('echo Hello').subscribe(output => console.log(output.stdout.toString())); // Hello\n 94 | 95 | exec('echo Hello') 96 | .pipe(trim()) 97 | .subscribe(output => console.log(output.stdout.toString())); // Hello 98 | ``` 99 | 100 | ### throwIf(pattern: string | RegExp) 101 | 102 | - manually throw error if contents of `stdout` or `stderr` is matching supplied pattern 103 | 104 | ```typescript 105 | import {throwIf} from 'rxjs-shell'; 106 | 107 | exec('echo Hello').pipe(throwIf(/Hello/)).subscribe(); // ERROR 108 | ``` 109 | 110 | ### throwIfStdout(pattern: string | RegExp) 111 | 112 | - manually throw error if contents of `stdout` is matching supplied pattern 113 | 114 | ```typescript 115 | import {throwIfStdout} from 'rxjs-shell'; 116 | 117 | exec('echo Hello').pipe(throwIfStdout(/Hello/)).subscribe(); // ERROR 118 | exec('>&2 echo Hello').pipe(throwIfStdout(/Hello/)).subscribe(); // OK 119 | ``` 120 | 121 | ### throwIfStderr(pattern: string | RegExp) 122 | 123 | - manually throw error if contents of `stderr` is matching supplied pattern 124 | 125 | ```typescript 126 | import {throwIfStderr} from 'rxjs-shell'; 127 | 128 | exec('echo Hello').pipe(throwIfStderr(/Hello/)).subscribe(); // OK 129 | exec('>&2 echo Hello').pipe(throwIfStderr(/Hello/)).subscribe(); // ERR 130 | ``` 131 | 132 | ### execWithStdin(command) 133 | 134 | - executes a command with a string event as stdin input 135 | 136 | ```typescript 137 | of('Hello World') 138 | .pipe(execWithStdin('cat -')) 139 | .subscribe(output => { 140 | expect(String(output.stdout).trim()).to.equal('Hello World'); 141 | }); 142 | ``` 143 | 144 | ## Utility Methods 145 | 146 | ### spawnEnd(spawnObservable: Observable) → Subject\<{stdout: Buffer, stderr: Buffer}\> 147 | 148 | - `spawn` emit each buffer from child process. if you want to connect other operator to this stream. use `spawnEnd` method. 149 | 150 | ```typescript 151 | import {spawn, spawnEnd} from 'rxjs-shell'; 152 | 153 | spawn('webpack', ['-p']) 154 | .pipe(outputChunk => { 155 | /* each child process's output buffer */ 156 | }) 157 | .subscribe(); 158 | 159 | spawnEnd(spawn('webpack', ['-p'])) 160 | .pipe(webpackOutput => { 161 | /* do something */ 162 | }) 163 | .subscribe(); 164 | ``` 165 | 166 | ### listenTerminating(fn: () => any) 167 | 168 | - invoke callbacks when one of signals that below is emitted. 169 | - `SIGINT` 170 | - `SIGBREAK` (for windows) 171 | 172 | basically each operators are listen that. if user pressed `^C` below stream is unsubscribe immediatly. 173 | 174 | ```typescript 175 | exec('curl ...') 176 | .pipe(concatMap(() => exec('curl ...'))) 177 | .subscribe(); 178 | ``` 179 | 180 | but if operators are not tied of one stream. whole process does not terminate. in this case. you can use `listenTerminating`. 181 | 182 | ```typescript 183 | import {exec, listenTerminating} from 'rxjs-shell'; 184 | 185 | // terminate process 186 | listenTerminating(code => process.exit(code)); 187 | async () => { 188 | // user pressing ^C while curl is running 189 | await exec('curl ...').toPromise(); 190 | 191 | // execute despite of pressing ^C. needs `listenTerminating` 192 | await exec('curl -X POST ...').toPromise(); 193 | }; 194 | ``` 195 | 196 | ## isSpawnChunk(obj: any): obj is SpawnChunk 197 | 198 | ## isExecOutput(obj: any): obj is ExecOutput 199 | 200 | ## Error Handling 201 | 202 | ```typescript 203 | import {ShellError, spawn} from 'rxjs-shell'; 204 | 205 | spawn('git clone http://github.com/johnny-mh/rxjs-shell-operators') 206 | .pipe(tap(chunk => process.stdout.write(String(chunk.chunk)))) 207 | .subscribe({ 208 | catch(err) { 209 | if (!(err instanceof ShellError)) { 210 | throw err; 211 | } 212 | 213 | console.log(err.originError); 214 | console.log(err.stdout); 215 | console.log(err.stderr); 216 | console.log(err.toAnnotatedString()); // print annotated errors 217 | }, 218 | }); 219 | ``` 220 | 221 | ## FAQ 222 | 223 | ### Operator does not throw script error 224 | 225 | Some shell script doesn't completed with Non-Zero code. they just emitting error message to `stderr` or `stdout` 😢. If so. hard to throw `ShellError` because of `err` is `null`. You can use `throwIf`, `throwIfStdout`, `throwIfStderr` operator manually throwing specific scripts. 226 | 227 | ```typescript 228 | exec('sh a.sh') 229 | .pipe(concatMap(() => exec('sh b.sh').pipe(throwIf(/ERROR:/)))) 230 | .subscribe(); 231 | ``` 232 | -------------------------------------------------------------------------------- /test/operators.spec.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import {expect} from 'chai'; 3 | import chaiExclude from 'chai-exclude'; 4 | import {of, tap} from 'rxjs'; 5 | import {TestScheduler} from 'rxjs/testing'; 6 | 7 | import { 8 | execWithStdin, 9 | throwIf, 10 | throwIfStderr, 11 | throwIfStdout, 12 | trim, 13 | } from '../src/operators'; 14 | import {ShellError} from '../src/util'; 15 | 16 | chai.use(chaiExclude); 17 | 18 | describe('operators.ts', () => { 19 | let scheduler: TestScheduler; 20 | 21 | beforeEach(() => { 22 | scheduler = new TestScheduler((actual, expected) => { 23 | chai.expect(actual).excludingEvery('stack').deep.equal(expected); 24 | }); 25 | }); 26 | 27 | describe('trim', () => { 28 | it('should trim ExecOutput contents', () => { 29 | scheduler.run(({cold, expectObservable}) => { 30 | const source$ = cold('-a', { 31 | a: {stdout: Buffer.from(' Hello '), stderr: Buffer.from(' World')}, 32 | }); 33 | 34 | expectObservable(source$.pipe(trim())).toBe('-x', { 35 | x: {stdout: Buffer.from('Hello'), stderr: Buffer.from('World')}, 36 | }); 37 | }); 38 | }); 39 | 40 | it('should trim SpawnChunk contents', () => { 41 | scheduler.run(({cold, expectObservable}) => { 42 | const source$ = cold('-a', { 43 | a: {type: 'stdout', chunk: Buffer.from(' Hello World ')}, 44 | }); 45 | 46 | expectObservable(source$.pipe(trim())).toBe('-x', { 47 | x: {type: 'stdout', chunk: Buffer.from('Hello World')}, 48 | }); 49 | }); 50 | }); 51 | 52 | it('should not handle other values', () => { 53 | scheduler.run(({cold, expectObservable}) => { 54 | const value = {hello: 'world'}; 55 | const source$ = cold('-a', {a: value}); 56 | 57 | expectObservable(source$.pipe(trim())).toBe('-x', { 58 | x: value, 59 | }); 60 | }); 61 | }); 62 | }); 63 | 64 | describe('throwIf', () => { 65 | it('should throw error by SpawnChunk contents pattern matching', () => { 66 | scheduler.run(({cold, expectObservable}) => { 67 | const source$ = cold('a', { 68 | a: { 69 | type: 'stdout', 70 | chunk: Buffer.from('Error: test error'), 71 | }, 72 | }); 73 | 74 | expectObservable(source$.pipe(throwIf('Error:'))).toBe( 75 | '#', 76 | null, 77 | new ShellError( 78 | 'throwIf: stdout is matching /Error:/', 79 | undefined, 80 | Buffer.from('Error: test error'), 81 | undefined 82 | ) 83 | ); 84 | }); 85 | }); 86 | 87 | it('should throw error by ExecOutput contents pattern matching', () => { 88 | scheduler.run(({cold, expectObservable}) => { 89 | const source$ = cold('a', { 90 | a: { 91 | stdout: Buffer.from('GREAT!'), 92 | stderr: Buffer.from(''), 93 | }, 94 | }); 95 | 96 | expectObservable(source$.pipe(throwIf(/GREAT!/))).toBe( 97 | '#', 98 | null, 99 | new ShellError( 100 | 'throwIf: stdout is matching /GREAT!/', 101 | undefined, 102 | Buffer.from('GREAT!'), 103 | undefined 104 | ) 105 | ); 106 | }); 107 | }); 108 | 109 | it('should not throw error when pattern is not matching', () => { 110 | scheduler.run(({cold, expectObservable}) => { 111 | const value = { 112 | stdout: Buffer.from('GREAT!'), 113 | stderr: Buffer.from(''), 114 | }; 115 | const source$ = cold('a', {a: value}); 116 | 117 | expectObservable(source$.pipe(throwIf(/NOTGREAT!/))).toBe('x', { 118 | x: value, 119 | }); 120 | }); 121 | }); 122 | 123 | it('should not handle other values', () => { 124 | scheduler.run(({cold, expectObservable}) => { 125 | const value = {hello: 'world'}; 126 | const source$ = cold('a', {a: value}); 127 | 128 | expectObservable(source$.pipe(throwIf(/GOOD/))).toBe('x', { 129 | x: value, 130 | }); 131 | }); 132 | }); 133 | }); 134 | 135 | describe('throwIfStdout', () => { 136 | it('should throw error by SpawnChunk stdout contents pattern matching', () => { 137 | scheduler.run(({cold, expectObservable}) => { 138 | const source$ = cold('a', { 139 | a: { 140 | type: 'stdout', 141 | chunk: Buffer.from('Error: test error'), 142 | }, 143 | }); 144 | 145 | expectObservable(source$.pipe(throwIfStdout('Error:'))).toBe( 146 | '#', 147 | null, 148 | new ShellError( 149 | 'throwIf: stdout is matching /Error:/', 150 | undefined, 151 | Buffer.from('Error: test error') 152 | ) 153 | ); 154 | }); 155 | }); 156 | 157 | it('should not throw error by SpawnChunk stderr contents pattern matching', () => { 158 | scheduler.run(({cold, expectObservable}) => { 159 | const value = { 160 | type: 'stderr', 161 | chunk: Buffer.from('Error: test error'), 162 | }; 163 | const source$ = cold('a', {a: value}); 164 | 165 | expectObservable(source$.pipe(throwIfStdout(/Error:/))).toBe('x', { 166 | x: value, 167 | }); 168 | }); 169 | }); 170 | 171 | it('should not throw error by SpawnChunk stdout contents pattern not matching', () => { 172 | scheduler.run(({cold, expectObservable}) => { 173 | const value = { 174 | type: 'stdout', 175 | chunk: Buffer.from('Error: test error'), 176 | }; 177 | const source$ = cold('a', {a: value}); 178 | 179 | expectObservable(source$.pipe(throwIfStdout(/NotError:/))).toBe('x', { 180 | x: value, 181 | }); 182 | }); 183 | }); 184 | 185 | it('should throw error by ExecOutput stdout contents pattern matching', () => { 186 | scheduler.run(({cold, expectObservable}) => { 187 | const source$ = cold('a', { 188 | a: { 189 | stdout: Buffer.from('Stdout: test'), 190 | stderr: Buffer.from('Stderr: test'), 191 | }, 192 | }); 193 | 194 | expectObservable(source$.pipe(throwIfStdout(/Stdout:/))).toBe( 195 | '#', 196 | null, 197 | new ShellError( 198 | 'throwIf: stdout is matching /Stdout:/', 199 | undefined, 200 | Buffer.from('Stdout: test') 201 | ) 202 | ); 203 | }); 204 | }); 205 | 206 | it('should not throw error by ExecOutput stderr despite of contents pattern matching', () => { 207 | scheduler.run(({cold, expectObservable}) => { 208 | const value = { 209 | stdout: Buffer.from('stdout'), 210 | stderr: Buffer.from('stderr'), 211 | }; 212 | const source$ = cold('a', {a: value}); 213 | 214 | expectObservable(source$.pipe(throwIfStdout(/stderr/))).toBe('x', { 215 | x: value, 216 | }); 217 | }); 218 | }); 219 | }); 220 | 221 | describe('throwIfStderr', () => { 222 | it('should throw error by SpawnChunk stderr contents pattern matching', () => { 223 | scheduler.run(({cold, expectObservable}) => { 224 | const source$ = cold('a', { 225 | a: { 226 | type: 'stderr', 227 | chunk: Buffer.from('Error: test error'), 228 | }, 229 | }); 230 | 231 | expectObservable(source$.pipe(throwIfStderr('Error:'))).toBe( 232 | '#', 233 | null, 234 | new ShellError( 235 | 'throwIf: stderr is matching /Error:/', 236 | undefined, 237 | undefined, 238 | Buffer.from('Error: test error') 239 | ) 240 | ); 241 | }); 242 | }); 243 | 244 | it('should not throw error by SpawnChunk stdout contents pattern matching', () => { 245 | scheduler.run(({cold, expectObservable}) => { 246 | const value = { 247 | type: 'stdout', 248 | chunk: Buffer.from('Error: test error'), 249 | }; 250 | const source$ = cold('a', {a: value}); 251 | 252 | expectObservable(source$.pipe(throwIfStderr(/Error:/))).toBe('x', { 253 | x: value, 254 | }); 255 | }); 256 | }); 257 | 258 | it('should not throw error by SpawnChunk stderr contents pattern not matching', () => { 259 | scheduler.run(({cold, expectObservable}) => { 260 | const value = { 261 | type: 'stderr', 262 | chunk: Buffer.from('Error: test error'), 263 | }; 264 | const source$ = cold('a', {a: value}); 265 | 266 | expectObservable(source$.pipe(throwIfStderr(/NotError:/))).toBe('x', { 267 | x: value, 268 | }); 269 | }); 270 | }); 271 | 272 | it('should throw error by ExecOutput stderr contents pattern matching', () => { 273 | scheduler.run(({cold, expectObservable}) => { 274 | const source$ = cold('a', { 275 | a: { 276 | stdout: Buffer.from('Stdout: test'), 277 | stderr: Buffer.from('Stderr: test'), 278 | }, 279 | }); 280 | 281 | expectObservable(source$.pipe(throwIfStderr(/Stderr:/))).toBe( 282 | '#', 283 | null, 284 | new ShellError( 285 | 'throwIf: stderr is matching /Stderr:/', 286 | undefined, 287 | undefined, 288 | Buffer.from('Stderr: test') 289 | ) 290 | ); 291 | }); 292 | }); 293 | 294 | it('should not throw error by ExecOutput stdout despite of contents pattern matching', () => { 295 | scheduler.run(({cold, expectObservable}) => { 296 | const value = { 297 | stdout: Buffer.from('stdout'), 298 | stderr: Buffer.from('stderr'), 299 | }; 300 | const source$ = cold('a', {a: value}); 301 | 302 | expectObservable(source$.pipe(throwIfStderr(/stdout/))).toBe('x', { 303 | x: value, 304 | }); 305 | }); 306 | }); 307 | }); 308 | 309 | describe('execWithStdin', () => { 310 | it('should exec with string input', done => { 311 | of('Hello World') 312 | .pipe(execWithStdin('cat -')) 313 | .subscribe(output => { 314 | expect(String(output.stdout).trim()).to.equal('Hello World'); 315 | done(); 316 | }); 317 | }); 318 | }); 319 | }); 320 | --------------------------------------------------------------------------------