├── .babelrc ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── __mock__ └── process.ts ├── __tests__ ├── for-await.ts ├── lib │ ├── cat.js │ ├── create-mock-output.ts │ ├── echo.js │ └── mock-stream.ts ├── pipe │ ├── fixture │ │ └── hello-world.txt │ ├── stdin.ts │ └── stdout.ts ├── promise.ts ├── shell.ts └── stdout-stderr.ts ├── a.js ├── jest.config.js ├── package.json ├── rollup.config.js ├── sample.ts ├── src ├── command.ts ├── env.ts ├── error.ts ├── index.ts ├── options.ts ├── process.ts ├── result.ts ├── shell.ts └── util.ts ├── tsconfig.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@babel/env", { "targets": { "node": "current" } }], "@babel/typescript"], 3 | "plugins": ["@babel/plugin-proposal-logical-assignment-operators"] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn pretty-quick --staged && yarn test -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "tabWidth": 4, 5 | "trailingComma": "es5", 6 | "printWidth": 100 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Sho Miyamoto 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### ⚠️WARNING⚠️ 2 | 3 | **Don't use this library anymore. This is unmaintained and could be buggy. You could use instead https://github.com/google/zx** 4 | 5 | 6 | # tish 7 | 8 | An easy, performant, portable and safe replacement of shell script with TypeScript, aiming to emulate shell in TypeScript instead of calling `child_process` in fragments. 9 | 10 | For those who love TypeScript and are tired of writing shell script. 11 | -------------------------------------------------------------------------------- /__mock__/process.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events' 2 | import { write } from 'fs' 3 | import { emit } from 'process' 4 | import { Duplex, PassThrough, Readable, Writable } from 'stream' 5 | import { MockReadable, MockWritable } from '../__tests__/lib/mock-stream' 6 | import { Process } from '../src/process' 7 | 8 | export class MockProcess extends EventEmitter implements Process { 9 | private static pid = 0 10 | private status: number 11 | 12 | pid = MockProcess.pid++ 13 | spawnfile = 'mock' 14 | spawnargs = [] 15 | 16 | // @ts-expect-error 17 | on: Process['on'] 18 | 19 | stdin: Writable 20 | stdout: Readable 21 | stderr: Readable 22 | 23 | private mock: Record> = { 24 | stdin: [], 25 | stdout: [], 26 | stderr: [], 27 | } 28 | 29 | constructor({ 30 | status, 31 | stdin = [], 32 | stdout = [], 33 | stderr = [], 34 | }: { 35 | status: number 36 | stdin?: string | Array 37 | stdout?: string | Array 38 | stderr?: string | Array 39 | }) { 40 | super() 41 | 42 | this.status = status 43 | 44 | stdin = typeof stdin === 'string' ? [stdin] : stdin 45 | 46 | this.stdin = new MockWritable() 47 | this.stdout = new MockReadable(typeof stdout === 'string' ? [stdout] : stdout) 48 | this.stderr = new MockReadable(typeof stderr === 'string' ? [stderr] : stderr) 49 | 50 | process.nextTick(() => this.emit('exit', this.status)) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /__tests__/for-await.ts: -------------------------------------------------------------------------------- 1 | import { $ } from '../src' 2 | import { MockProcess } from '../__mock__/process' 3 | 4 | process.on('unhandledRejection', () => process.exit(1)) 5 | 6 | describe('for...await', () => { 7 | test('read stdout by line', async () => { 8 | const proc = new MockProcess({ 9 | status: 0, 10 | stdout: 'line1\nline2\n', 11 | }) 12 | 13 | const command = $(proc) 14 | 15 | const buf = [] 16 | 17 | for await (const line of command) { 18 | buf.push(line) 19 | } 20 | 21 | expect(buf).toStrictEqual(['line1', 'line2']) 22 | }) 23 | 24 | test('read stdout by line of chunked output', async () => { 25 | const proc = new MockProcess({ 26 | status: 0, 27 | stdout: ['lin', 'e1\nli', 'ne2\n'], 28 | }) 29 | 30 | const command = $(proc) 31 | 32 | const buf = [] 33 | 34 | for await (const line of command) { 35 | buf.push(line) 36 | } 37 | 38 | expect(buf).toStrictEqual(['line1', 'line2']) 39 | }) 40 | 41 | test('read stdout by line even if error', async () => { 42 | const proc = new MockProcess({ 43 | status: 1, 44 | stdout: 'line1\nline2\n', 45 | }) 46 | 47 | const command = $(proc) 48 | 49 | const buf = [] 50 | 51 | for await (const line of command) { 52 | buf.push(line) 53 | } 54 | 55 | expect(buf).toStrictEqual(['line1', 'line2']) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /__tests__/lib/cat.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | process.stdin.pipe(process.stdout) 4 | -------------------------------------------------------------------------------- /__tests__/lib/create-mock-output.ts: -------------------------------------------------------------------------------- 1 | import { Writable, PassThrough } from 'stream' 2 | 3 | export const createMockOutput = (): { res: Array; mock: Writable } => { 4 | const res: Array = [] 5 | 6 | const mock = new PassThrough() 7 | mock.setEncoding('utf8') 8 | mock.on('data', (chunk) => res.push(chunk)) 9 | 10 | return { res, mock } 11 | } 12 | -------------------------------------------------------------------------------- /__tests__/lib/echo.js: -------------------------------------------------------------------------------- 1 | console.log(process.argv.slice(2).join(' ')) 2 | -------------------------------------------------------------------------------- /__tests__/lib/mock-stream.ts: -------------------------------------------------------------------------------- 1 | import { Readable, Writable } from 'stream' 2 | 3 | export class MockReadable extends Readable { 4 | private _source: Array 5 | 6 | constructor(source: Array) { 7 | super() 8 | 9 | this._source = source 10 | } 11 | 12 | public _read() { 13 | for (const chunk of this._source) { 14 | this.push(Buffer.from(chunk)) 15 | } 16 | 17 | this.push(null) 18 | } 19 | } 20 | 21 | export class MockWritable extends Writable { 22 | public _write(chunk: Buffer, _: unknown, cb: Function) { 23 | cb() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /__tests__/pipe/fixture/hello-world.txt: -------------------------------------------------------------------------------- 1 | Hello, world. -------------------------------------------------------------------------------- /__tests__/pipe/stdin.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'stream' 2 | 3 | import { $ } from '../../src' 4 | import { MockProcess } from '../../__mock__/process' 5 | import { MockReadable } from '../lib/mock-stream' 6 | 7 | process.on('unhandledRejection', () => process.exit(1)) 8 | 9 | const FIXTURE_TEXT = 'Hello, world.' 10 | 11 | function fromString(source: Array): Readable { 12 | return new Readable({ 13 | read() { 14 | for (const chunk of source) { 15 | this.push(chunk, 'utf8') 16 | } 17 | this.push(null) 18 | }, 19 | }) 20 | } 21 | 22 | function toString(readable: Readable): Promise { 23 | return new Promise((resolve, reject) => { 24 | const buf: Array = [] 25 | readable.on('data', (chunk) => buf.push(chunk)) 26 | readable.on('end', () => resolve(Buffer.concat(buf).toString())) 27 | readable.on('error', reject) 28 | }) 29 | } 30 | 31 | describe.skip('command samples', () => { 32 | test('cat', async () => { 33 | expect(await toString(fromString(['hello']).pipe($('cat')))).toBe('hello') 34 | }) 35 | test('grep', async () => { 36 | expect(await toString(fromString(['hello']).pipe($('grep hello')))).toBe('hello\n') 37 | }) 38 | test('grep -o', async () => { 39 | expect(await toString(fromString(['hello']).pipe($('grep -o he')))).toBe('he\n') 40 | }) 41 | test('sed', async () => { 42 | expect(await toString(fromString(['hello']).pipe($('sed "s/hello/こんにちは/"')))).toBe( 43 | 'こんにちは\n' 44 | ) 45 | }) 46 | }) 47 | 48 | describe('pipe', () => { 49 | describe('stdin', () => { 50 | it('from stream', () => { 51 | const source = new MockReadable(FIXTURE_TEXT.split('')) 52 | 53 | const proc = new MockProcess({ status: 0 }) 54 | 55 | const command = $(proc) 56 | 57 | const spies = { 58 | source: { 59 | push: jest.spyOn(source, 'push'), 60 | }, 61 | command: { 62 | write: jest.spyOn(command, 'write'), 63 | }, 64 | proc: { 65 | stdin: { 66 | write: jest.spyOn(proc.stdin, 'write'), 67 | }, 68 | }, 69 | } 70 | 71 | source.pipe(command) 72 | 73 | return Promise.all([ 74 | new Promise((resolve, reject) => source.once('end', resolve).once('error', reject)), 75 | new Promise((resolve, reject) => 76 | command.once('finish', resolve).once('error', reject) 77 | ), 78 | new Promise((resolve, reject) => 79 | proc.stdin.once('finish', resolve).once('error', reject) 80 | ), 81 | ]).then(() => { 82 | expect( 83 | Buffer.concat( 84 | spies.source.push.mock.calls.map(([buf]) => buf).filter(Boolean) 85 | ).toString() 86 | ).toBe('Hello, world.') 87 | 88 | expect( 89 | Buffer.concat( 90 | spies.command.write.mock.calls.map(([buf]) => buf).filter(Boolean) 91 | ).toString() 92 | ).toBe('Hello, world.') 93 | 94 | expect( 95 | Buffer.concat( 96 | spies.proc.stdin.write.mock.calls.map(([buf]) => buf).filter(Boolean) 97 | ).toString() 98 | ).toBe('Hello, world.') 99 | }) 100 | }) 101 | 102 | it.todo('from command stdout') 103 | it.todo('from child_process stdin') 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /__tests__/pipe/stdout.ts: -------------------------------------------------------------------------------- 1 | import { $ } from '../../src' 2 | import { MockProcess } from '../../__mock__/process' 3 | import { MockWritable } from '../lib/mock-stream' 4 | 5 | process.on('unhandledRejection', () => process.exit(1)) 6 | 7 | describe('pipe', () => { 8 | describe('stdout', () => { 9 | it('to stream', () => { 10 | const destination = new MockWritable() 11 | 12 | const proc = new MockProcess({ 13 | status: 0, 14 | stdout: 'Hello, world.', 15 | }) 16 | 17 | const command = $(proc) 18 | 19 | const spies = { 20 | command: { 21 | push: jest.spyOn(command, 'push'), 22 | }, 23 | proc: { 24 | stdout: { 25 | push: jest.spyOn(proc.stdout, 'push'), 26 | }, 27 | }, 28 | destination: { 29 | write: jest.spyOn(destination, 'write'), 30 | }, 31 | } 32 | 33 | command.pipe(destination) 34 | 35 | setTimeout(() => destination.emit('finish'), 1) 36 | 37 | return Promise.all([ 38 | new Promise((resolve, reject) => 39 | proc.stdout.once('end', resolve).once('error', reject) 40 | ), 41 | new Promise((resolve, reject) => 42 | command.once('end', resolve).once('error', reject) 43 | ), 44 | new Promise((resolve, reject) => 45 | destination.once('finish', resolve).once('error', reject) 46 | ), 47 | ]).then(() => { 48 | expect( 49 | Buffer.concat( 50 | spies.proc.stdout.push.mock.calls.map(([buf]) => buf).filter(Boolean) 51 | ).toString() 52 | ).toBe('Hello, world.') 53 | 54 | expect( 55 | Buffer.concat( 56 | spies.command.push.mock.calls.map(([buf]) => buf).filter(Boolean) 57 | ).toString() 58 | ).toBe('Hello, world.') 59 | 60 | expect( 61 | Buffer.concat( 62 | spies.destination.write.mock.calls.map(([buf]) => buf).filter(Boolean) 63 | ).toString() 64 | ).toBe('Hello, world.') 65 | }) 66 | }) 67 | 68 | it.todo('to child_process stdin') 69 | it.todo('to command stdin') 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /__tests__/promise.ts: -------------------------------------------------------------------------------- 1 | import { $ } from '../src' 2 | import { CommandError } from '../src/error' 3 | import { CommandResult } from '../src/result' 4 | import { MockProcess } from '../__mock__/process' 5 | 6 | process.on('unhandledRejection', () => process.exit(1)) 7 | 8 | describe('promise', () => { 9 | beforeEach(() => { 10 | // ensure there's at least one assertion run for every test case 11 | expect.hasAssertions() 12 | }) 13 | 14 | describe('then', () => { 15 | test('resolve CommandResult when command exit successfully', () => { 16 | const proc = new MockProcess({ 17 | status: 0, 18 | }) 19 | 20 | const command = $(proc) 21 | 22 | const procOnExit = jest.fn().mockImplementation((status: number) => { 23 | expect(status).toBe(0) 24 | }) 25 | 26 | proc.once('exit', procOnExit) 27 | 28 | const onResolved = jest.fn().mockImplementation((result: CommandResult) => { 29 | expect(result.status).toBe(0) 30 | expect(result.command).toBe(command) 31 | }) 32 | const onRejected = jest.fn() 33 | 34 | return command.then(onResolved, onRejected).finally(() => { 35 | expect(procOnExit).toHaveBeenCalled() 36 | expect(onResolved).toHaveBeenCalled() 37 | expect(onRejected).not.toHaveBeenCalled() 38 | }) 39 | }) 40 | 41 | test('reject CommandError when command fails', () => { 42 | const proc = new MockProcess({ 43 | status: 1, 44 | }) 45 | 46 | const command = $(proc) 47 | 48 | const procOnExit = jest.fn().mockImplementation((status: number) => { 49 | expect(status).toBe(1) 50 | }) 51 | 52 | proc.once('exit', procOnExit) 53 | 54 | const onResolved = jest.fn() 55 | const onRejected = jest.fn().mockImplementation((error: unknown) => { 56 | expect(error).toBeInstanceOf(CommandError) 57 | expect((error as CommandError).status).toBe(1) 58 | expect((error as CommandError).command).toBe(command) 59 | }) 60 | 61 | return command.then(onResolved, onRejected).finally(() => { 62 | expect(procOnExit).toHaveBeenCalled() 63 | expect(onResolved).not.toHaveBeenCalled() 64 | expect(onRejected).toHaveBeenCalled() 65 | }) 66 | }) 67 | }) 68 | 69 | describe('catch', () => { 70 | test('reject CommandError when command fails', () => { 71 | const proc = new MockProcess({ 72 | status: 1, 73 | }) 74 | 75 | const command = $(proc) 76 | 77 | const procOnExit = jest.fn().mockImplementation((status: number) => { 78 | expect(status).toBe(1) 79 | }) 80 | 81 | proc.once('exit', procOnExit) 82 | 83 | const onRejected = jest.fn().mockImplementation((error: unknown) => { 84 | expect(error).toBeInstanceOf(CommandError) 85 | expect((error as CommandError).status).toBe(1) 86 | expect((error as CommandError).command).toBe(command) 87 | }) 88 | 89 | return command.catch(onRejected).finally(() => { 90 | expect(procOnExit).toHaveBeenCalled() 91 | expect(onRejected).toHaveBeenCalled() 92 | }) 93 | }) 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /__tests__/shell.ts: -------------------------------------------------------------------------------- 1 | import { CommandFactory, shell } from '../src/shell' 2 | import { MockProcess } from '../__mock__/process' 3 | 4 | process.on('unhandledRejection', () => process.exit(1)) 5 | 6 | describe('shell', () => { 7 | beforeEach(() => { 8 | // ensure there's at least one assertion run for every test case 9 | expect.hasAssertions() 10 | }) 11 | 12 | test('creates a new factory', () => { 13 | const proc = new MockProcess({ 14 | status: 0, 15 | }) 16 | 17 | const options = Object.freeze({ 18 | env: { SHELL_TEST: 'true' }, 19 | cwd: 'my_dir', 20 | stream: { allowHalfOpen: true }, 21 | timeout: 10, 22 | }) 23 | 24 | const $ = shell(options) 25 | 26 | expect($(proc).options).toStrictEqual({ ...options }) 27 | }) 28 | 29 | test('extends a factory', () => { 30 | const proc = new MockProcess({ 31 | status: 0, 32 | }) 33 | 34 | const options = Object.freeze({ 35 | env: { SHELL_TEST_1: 'true' }, 36 | cwd: 'my_dir_1', 37 | stream: { allowHalfOpen: true }, 38 | timeout: 10, 39 | }) 40 | 41 | let $: CommandFactory 42 | 43 | $ = shell(options) 44 | 45 | $ = shell( 46 | Object.freeze({ 47 | env: { 48 | SHELL_TEST_2: 'true', 49 | }, 50 | cwd: 'my_dir_2', 51 | stream: { 52 | autoDestroy: true, 53 | }, 54 | timeout: 20, 55 | }), 56 | $ 57 | ) 58 | 59 | const command = $(proc) 60 | 61 | expect(command.options).not.toStrictEqual({ ...options }) 62 | expect(command.options).toStrictEqual({ 63 | env: { 64 | SHELL_TEST_1: 'true', 65 | SHELL_TEST_2: 'true', 66 | }, 67 | cwd: 'my_dir_2', 68 | stream: { 69 | allowHalfOpen: true, 70 | autoDestroy: true, 71 | }, 72 | timeout: 20, 73 | }) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /__tests__/stdout-stderr.ts: -------------------------------------------------------------------------------- 1 | import { $, stderr, stdout, stdouterr } from '../src' 2 | import { MockProcess } from '../__mock__/process' 3 | 4 | describe('stdout', () => { 5 | beforeEach(() => { 6 | // ensure there's at least one assertion run for every test case 7 | expect.hasAssertions() 8 | }) 9 | 10 | test('stdout', () => { 11 | const proc = new MockProcess({ 12 | status: 0, 13 | stdout: 'hello', 14 | }) 15 | 16 | const command = $(proc) 17 | 18 | const onResolved = (stdout: string | null) => { 19 | expect(stdout).toBe('hello') 20 | } 21 | 22 | return Promise.all([stdout(command)?.then(onResolved), stdout(command)?.then(onResolved)]) 23 | }) 24 | 25 | test('stderr', () => { 26 | const proc = new MockProcess({ 27 | status: 0, 28 | stderr: 'world', 29 | }) 30 | 31 | const command = $(proc) 32 | 33 | const onResolved = (stderr: string | null) => { 34 | expect(stderr).toBe('world') 35 | } 36 | 37 | return Promise.all([stderr(command)?.then(onResolved), stderr(command)?.then(onResolved)]) 38 | }) 39 | 40 | test('stdouterr', () => { 41 | const proc = new MockProcess({ 42 | status: 0, 43 | stdout: 'hello, ', 44 | stderr: 'world', 45 | }) 46 | 47 | const command = $(proc) 48 | 49 | const onResolved = (stdouterr: string) => { 50 | expect(stdouterr).toBe('hello, world') 51 | } 52 | 53 | return Promise.all([ 54 | stdouterr(command)?.then(onResolved), 55 | stdouterr(command)?.then(onResolved), 56 | ]) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /a.js: -------------------------------------------------------------------------------- 1 | /** @type {import('./src')} */ 2 | const tish = require('./dist') 3 | 4 | const { $, Command, stdout, stderr, stdouterr, CommandError } = tish 5 | 6 | main() 7 | 8 | async function main() { 9 | try { 10 | await $('git a') 11 | } catch (err) { 12 | console.error(err) 13 | 14 | const { status, command } = err 15 | console.error(await stderr(command)) 16 | } 17 | 18 | try { 19 | // console.log(await stdout($('git log --oneline').pipe($('grep fix')))) 20 | // for await (const log of $('git log --oneline').pipe($('head'))) { 21 | // const hash = log.slice(0, 7) 22 | // const message = log.slice(8) 23 | // console.log({ hash, message }) 24 | // } 25 | // const out = await stdout($('git log --').pipe($('head'))) 26 | // console.log(out) 27 | // const out = await stdout( 28 | // $('echo hello, world.').pipe($('grep -o world.')).pipe($('xargs echo hello,')) 29 | // ) 30 | // console.log(out) 31 | } catch (err) { 32 | if (err instanceof CommandError) { 33 | const { status, command } = err 34 | 35 | console.log('catched: status=%s', command._id) 36 | 37 | await stdouterr(command) 38 | .then((str) => { 39 | console.log('done') 40 | console.log('==========') 41 | console.log(str) 42 | }) 43 | .catch((err) => { 44 | console.log('err') 45 | console.log('==========') 46 | console.log(err) 47 | }) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/private/var/folders/cl/thz9nv6x3y71h9pd7mtz_zp40000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | // clearMocks: false, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: undefined, 25 | 26 | // The directory where Jest should output its coverage files 27 | coverageDirectory: 'coverage', 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: undefined, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: undefined, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files using an array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | // globalSetup: undefined, 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | // globalTeardown: undefined, 59 | 60 | // A set of global variables that need to be available in all test environments 61 | // globals: {}, 62 | 63 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 64 | // maxWorkers: "50%", 65 | 66 | // An array of directory names to be searched recursively up from the requiring module's location 67 | // moduleDirectories: [ 68 | // "node_modules" 69 | // ], 70 | 71 | // An array of file extensions your modules use 72 | // moduleFileExtensions: [ 73 | // "js", 74 | // "json", 75 | // "jsx", 76 | // "ts", 77 | // "tsx", 78 | // "node" 79 | // ], 80 | 81 | // A map from regular expressions to module names that allow to stub out resources with a single module 82 | // moduleNameMapper: {}, 83 | 84 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 85 | // modulePathIgnorePatterns: [], 86 | 87 | // Activates notifications for test results 88 | // notify: false, 89 | 90 | // An enum that specifies notification mode. Requires { notify: true } 91 | // notifyMode: "failure-change", 92 | 93 | // A preset that is used as a base for Jest's configuration 94 | // preset: undefined, 95 | 96 | // Run tests from one or more projects 97 | // projects: undefined, 98 | 99 | // Use this configuration option to add custom reporters to Jest 100 | // reporters: undefined, 101 | 102 | // Automatically reset mock state between every test 103 | // resetMocks: false, 104 | 105 | // Reset the module registry before running each individual test 106 | // resetModules: false, 107 | 108 | // A path to a custom resolver 109 | // resolver: undefined, 110 | 111 | // Automatically restore mock state between every test 112 | // restoreMocks: false, 113 | 114 | // The root directory that Jest should scan for tests and modules within 115 | // rootDir: undefined, 116 | 117 | // A list of paths to directories that Jest should use to search for files in 118 | // roots: [ 119 | // "" 120 | // ], 121 | 122 | // Allows you to use a custom runner instead of Jest's default test runner 123 | // runner: "jest-runner", 124 | 125 | // The paths to modules that run some code to configure or set up the testing environment before each test 126 | // setupFiles: [], 127 | 128 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 129 | // setupFilesAfterEnv: [], 130 | 131 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 132 | // snapshotSerializers: [], 133 | 134 | // The test environment that will be used for testing 135 | testEnvironment: 'node', 136 | 137 | // Options that will be passed to the testEnvironment 138 | // testEnvironmentOptions: {}, 139 | 140 | // Adds a location field to test results 141 | // testLocationInResults: false, 142 | 143 | // The glob patterns Jest uses to detect test files 144 | // testMatch: [ 145 | // "**/__tests__/**/*.[jt]s?(x)", 146 | // "**/?(*.)+(spec|test).[tj]s?(x)" 147 | // ], 148 | 149 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 150 | testPathIgnorePatterns: ['/node_modules/', '/__tests__/lib/'], 151 | 152 | // The regexp pattern or array of patterns that Jest uses to detect test files 153 | // testRegex: [], 154 | 155 | // This option allows the use of a custom results processor 156 | // testResultsProcessor: undefined, 157 | 158 | // This option allows use of a custom test runner 159 | // testRunner: "jasmine2", 160 | 161 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 162 | // testURL: "http://localhost", 163 | 164 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 165 | // timers: "real", 166 | 167 | // A map from regular expressions to paths to transformers 168 | // transform: undefined, 169 | 170 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 171 | // transformIgnorePatterns: [ 172 | // "/node_modules/" 173 | // ], 174 | 175 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 176 | // unmockedModulePathPatterns: undefined, 177 | 178 | // Indicates whether each individual test should be reported during the run 179 | // verbose: undefined, 180 | 181 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 182 | // watchPathIgnorePatterns: [], 183 | 184 | // Whether to use watchman for file crawling 185 | // watchman: true, 186 | } 187 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tish", 3 | "version": "0.1.1", 4 | "main": "dist/index.js", 5 | "module": "dist/index.mjs", 6 | "source": "src/index.ts", 7 | "types": "src/index.ts", 8 | "repository": "git@github.com:shqld/tish.git", 9 | "author": "Sho Miyamoto ", 10 | "license": "MIT", 11 | "files": [ 12 | "dist/*", 13 | "src/*" 14 | ], 15 | "scripts": { 16 | "prepare": "husky install", 17 | "dev": "babel-node -x .ts", 18 | "build": "yarn tsc -d --emitDeclarationOnly && yarn rollup -c", 19 | "test": "tsc && jest", 20 | "typecheck": "tsc --noEmit", 21 | "prepublishOnly": "yarn build" 22 | }, 23 | "dependencies": { 24 | "cross-spawn": "^7.0.2", 25 | "debug": "^4.3.1", 26 | "deepmerge": "^4.2.2", 27 | "pump": "^3.0.0" 28 | }, 29 | "devDependencies": { 30 | "@babel/cli": "^7.8.3", 31 | "@babel/core": "^7.8.3", 32 | "@babel/node": "^7.8.3", 33 | "@babel/plugin-proposal-logical-assignment-operators": "^7.12.13", 34 | "@babel/preset-env": "^7.8.3", 35 | "@babel/preset-typescript": "^7.8.3", 36 | "@rollup/plugin-commonjs": "^17.1.0", 37 | "@rollup/plugin-node-resolve": "^11.2.0", 38 | "@rollup/plugin-replace": "^2.3.2", 39 | "@types/cross-spawn": "^6.0.2", 40 | "@types/debug": "^4.1.5", 41 | "@types/jest": "^26.0.20", 42 | "@types/node": "^14.14.31", 43 | "@types/pump": "^1.1.1", 44 | "command-exists": "^1.2.9", 45 | "husky": "^5.1.0", 46 | "jest": "^26.6.3", 47 | "prettier": "^2.0.4", 48 | "pretty-quick": "^3.1.0", 49 | "rollup": "^2.39.0", 50 | "rollup-plugin-auto-external": "^2.0.0", 51 | "rollup-plugin-babel": "^4.4.0", 52 | "rollup-plugin-terser": "^7.0.2", 53 | "source-map-support": "^0.5.19", 54 | "typescript": "^4.2.4" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve' 2 | import commonjs from '@rollup/plugin-commonjs' 3 | import replace from '@rollup/plugin-replace' 4 | import external from 'rollup-plugin-auto-external' 5 | import babel from 'rollup-plugin-babel' 6 | import { terser } from 'rollup-plugin-terser' 7 | 8 | const isDev = process.env.BUILD_ENV === 'development' 9 | 10 | /** @type {import('rollup').RollupOptions} */ 11 | const config = { 12 | input: 'src/index.ts', 13 | output: [ 14 | { 15 | file: 'dist/index.js', 16 | format: 'cjs', 17 | sourcemap: true, 18 | }, 19 | { 20 | file: 'dist/index.mjs', 21 | format: 'esm', 22 | sourcemap: true, 23 | }, 24 | ], 25 | plugins: [ 26 | resolve({ 27 | extensions: ['.ts'], 28 | }), 29 | commonjs(), 30 | replace({ 31 | 'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'), 32 | }), 33 | external(), 34 | babel({ 35 | exclude: 'node_modules/**', 36 | extensions: ['.ts'], 37 | }), 38 | !isDev && terser(), 39 | ], 40 | } 41 | 42 | export default config 43 | -------------------------------------------------------------------------------- /sample.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { $, args, isSuccessful, stderr, stdout, stdouterr } from './src' 3 | 4 | async function main() { 5 | $('echo hello').pipe($('asdf')) 6 | 7 | $('echo hello').pipe(fs.createWriteStream('aaa')) 8 | 9 | fs.createReadStream('a').pipe($('cat')) 10 | 11 | if (await $('echo 111')) { 12 | } 13 | 14 | for await (const a of $('aaa')) { 15 | } 16 | 17 | $('adsf') 18 | .then(() => $('asdf')) 19 | .catch(() => $('adsf')) 20 | 21 | if (await isSuccessful($('asdf'))) { 22 | console.log('========') 23 | } 24 | 25 | const out = await stdout($('asdf')) 26 | const err = await stderr($('asdf')) 27 | const outerr = await stdouterr($('asdf')) 28 | 29 | console.log({ 30 | out, 31 | err, 32 | outerr, 33 | }) 34 | 35 | // const shell = new Shell() 36 | // const { $ } = shell({ 37 | // env: {}, 38 | // cwd: {}, 39 | // }) 40 | 41 | // Subshell? 42 | // $.sub({}) 43 | 44 | const something = 1 45 | 46 | // Conditional args 47 | $( 48 | 'asdf', 49 | args({ 50 | '--asdf': !something, 51 | '-n': something && 2, 52 | }) 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/command.ts: -------------------------------------------------------------------------------- 1 | import * as rl from 'readline' 2 | import { Duplex, Readable } from 'stream' 3 | import { ChildProcess } from 'child_process' 4 | 5 | import spawn from 'cross-spawn' 6 | import _debug, { Debugger } from 'debug' 7 | 8 | import type { Process } from './process' 9 | import type { CommandResult } from './result' 10 | import { CommandError } from './error' 11 | import { cloneReadable } from './util' 12 | import type { Options } from './options' 13 | 14 | const debug = _debug('tish') 15 | 16 | type OnFulfilled = (result: CommandResult) => T 17 | type OnRejected = (error: unknown | CommandError) => T 18 | 19 | export class Command extends Duplex implements Promise { 20 | public [Symbol.toStringTag] = 'Command' 21 | 22 | public readonly _id: number 23 | public readonly _name: string 24 | public readonly _args: Array 25 | public _stdout: Readable | null 26 | public _stderr: Readable | null 27 | 28 | private _proc: Process 29 | private readonly _debug: Debugger 30 | public readonly options: Options 31 | 32 | constructor(command: string | Process, options: Options) { 33 | super(options.stream) 34 | 35 | this.options = options 36 | 37 | if (typeof command === 'string') { 38 | const [name, ...args] = command.split(' ') 39 | 40 | this._proc = spawn(name, args, { 41 | stdio: 'pipe', 42 | cwd: options.cwd, 43 | env: options.env, 44 | timeout: options.timeout, 45 | }) 46 | this._name = name 47 | this._args = args 48 | } else { 49 | this._proc = command 50 | this._name = this._proc.spawnfile 51 | this._args = this._proc.spawnargs 52 | } 53 | 54 | this._id = this._proc.pid 55 | this._debug = debug.extend(this._name).extend((this._id as unknown) as string) 56 | 57 | this._debug('spawn (pid: %s)', this._id) 58 | 59 | if (this._proc.stdin) { 60 | this.once('unpipe', () => { 61 | this._proc.stdin!.end() 62 | }) 63 | this.once('end', () => { 64 | this._proc.stdin!.end() 65 | }) 66 | } 67 | 68 | this._stdout = this._proc.stdout 69 | this._stderr = this._proc.stderr 70 | 71 | if (this._proc.stdout) { 72 | let backpressured = false 73 | 74 | this._proc.stdout.on('data', (data) => { 75 | if (backpressured) { 76 | this.once('drain', () => { 77 | backpressured = false 78 | this.push(data) 79 | }) 80 | } else if (!this.push(data)) { 81 | backpressured = true 82 | this._debug('backpressure') 83 | } 84 | }) 85 | 86 | this._proc.stdout.once('end', () => { 87 | process.nextTick(() => this.push(null)) 88 | }) 89 | } 90 | } 91 | 92 | get argv(): string { 93 | return [this._name, this._args].join(' ') 94 | } 95 | 96 | get process(): Readonly { 97 | return this._proc as ChildProcess 98 | } 99 | 100 | public [Symbol.asyncIterator](): AsyncIterableIterator { 101 | return rl 102 | .createInterface({ 103 | input: this, 104 | crlfDelay: Infinity, 105 | }) 106 | [Symbol.asyncIterator]() 107 | } 108 | 109 | public then(): Promise 110 | public then(onFulfilled: OnFulfilled): Promise 111 | public then(onFulfilled: OnFulfilled, onRejected: OnRejected): Promise 112 | public then(onFulfilled?: OnFulfilled, onRejected?: OnRejected): Promise { 113 | return this._chain.then(onFulfilled, onRejected) 114 | } 115 | 116 | public catch(): Promise 117 | public catch(onRejected: OnRejected): Promise 118 | public catch(onRejected?: OnRejected): Promise { 119 | return this._chain.catch(onRejected) 120 | } 121 | 122 | public finally(onFinally?: () => void) { 123 | return this._chain.finally(onFinally) 124 | } 125 | 126 | public _read(_size: number): void {} 127 | public _write( 128 | chunk: any, 129 | encoding: BufferEncoding, 130 | done: (error: Error | null | undefined) => void 131 | ): void { 132 | this._proc.stdin?.write(chunk, encoding, done) 133 | } 134 | 135 | private _promise: Promise = Promise.resolve() 136 | private get _chain(): Promise { 137 | return this._promise.then( 138 | () => 139 | new Promise((resolve, reject) => { 140 | this._proc.on('exit', (status) => { 141 | if (typeof status !== 'number') { 142 | throw new Error('Command exited unsuccessfully') 143 | } 144 | 145 | const result = { status, command: this } 146 | 147 | if (status === 0) { 148 | resolve(result) 149 | } else { 150 | if (this._proc.stdout) { 151 | this._stdout = cloneReadable(this._proc.stdout) 152 | } 153 | if (this._proc.stderr) { 154 | this._stderr = cloneReadable(this._proc.stderr) 155 | } 156 | 157 | reject(new CommandError(result)) 158 | } 159 | }) 160 | }) 161 | ) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | export const __DEV__ = process.env.NODE_ENV === 'development' 2 | -------------------------------------------------------------------------------- /src/error.ts: -------------------------------------------------------------------------------- 1 | import { Command } from './command' 2 | import type { CommandResult } from './result' 3 | 4 | export class CommandError extends Error { 5 | status!: number 6 | command!: Command 7 | 8 | constructor({ status, command }: CommandResult) { 9 | super() 10 | 11 | Error.captureStackTrace?.(this, CommandError) 12 | 13 | const message = `Command "${command.argv}" failed with exit code ${status}` 14 | 15 | Object.defineProperties(this, { 16 | message: { 17 | get() { 18 | return message 19 | }, 20 | }, 21 | status: { 22 | get() { 23 | return status 24 | }, 25 | }, 26 | command: { 27 | get() { 28 | return command 29 | }, 30 | }, 31 | }) 32 | } 33 | } 34 | 35 | // Avoid mangling for the error name 36 | Object.defineProperty(CommandError, 'name', { value: 'CommmandError' }) 37 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Command } from './command' 2 | import { defaultOptions } from './options' 3 | import { shell, CommandFactory } from './shell' 4 | import { isNonNullable } from './util' 5 | 6 | export * from './command' 7 | export { CommandError } from './error' 8 | export { shell } from './shell' 9 | 10 | export const $: CommandFactory = shell(defaultOptions) 11 | 12 | export function isSuccessful(command: Command): Promise { 13 | return command.then( 14 | () => true, 15 | () => false 16 | ) 17 | } 18 | 19 | export function stdout(command: Command): Promise { 20 | if (!command._stdout) return Promise.resolve(null) 21 | 22 | const stream = command._stdout 23 | const buf: Array = [] 24 | const push = (chunk: Buffer) => buf.push(chunk) 25 | 26 | // FIXME: clone readable 27 | return new Promise((resolve, reject) => 28 | stream 29 | .on('data', push) 30 | .once('end', () => { 31 | stream.off('data', push) 32 | resolve(Buffer.concat(buf).toString().trim()) 33 | }) 34 | .once('error', (err) => { 35 | stream.off('data', push) 36 | reject(err) 37 | }) 38 | .once('close', () => { 39 | reject(new Error('Stream already closed')) 40 | }) 41 | ) 42 | } 43 | 44 | export function stderr(command: Command): Promise { 45 | if (!command._stderr) return Promise.resolve(null) 46 | 47 | const stream = command._stderr 48 | const buf: Array = [] 49 | const push = (chunk: Buffer) => buf.push(chunk) 50 | 51 | if (stream.destroyed) { 52 | throw new Error('stderr has already been destroyed') 53 | } 54 | 55 | // FIXME: clone readable 56 | return new Promise((resolve, reject) => 57 | stream 58 | .on('data', push) 59 | .once('end', () => { 60 | stream.off('data', push) 61 | resolve(Buffer.concat(buf).toString().trim()) 62 | }) 63 | .once('error', (err) => { 64 | stream.off('data', push) 65 | reject(err) 66 | }) 67 | .once('close', () => { 68 | reject(new Error('Stream already closed')) 69 | }) 70 | ) 71 | } 72 | 73 | export function stdouterr(command: Command): Promise { 74 | const { _stdout, _stderr } = command 75 | 76 | const streams = [_stdout, _stderr].filter(isNonNullable) 77 | const buf: Array = [] 78 | const push = (chunk: Buffer) => buf.push(chunk) 79 | 80 | return new Promise((resolve, reject) => 81 | streams.map((stream) => 82 | stream 83 | .on('data', push) 84 | .once('end', () => { 85 | stream.off('data', push) 86 | resolve(Buffer.concat(buf).toString().trim()) 87 | }) 88 | .once('error', (err) => { 89 | stream.off('data', push) 90 | reject(err) 91 | }) 92 | ) 93 | ) 94 | } 95 | 96 | type Falsy = null | undefined 97 | 98 | export function args(args: Record): Array { 99 | const _args = [] 100 | 101 | for (const key in args) { 102 | const val = args[key] 103 | 104 | if (typeof val === 'string') { 105 | _args.push(key, val) 106 | } else if (typeof val === 'number') { 107 | _args.push(key, val.toString()) 108 | } else if (val) { 109 | _args.push(key) 110 | } 111 | } 112 | 113 | return _args 114 | } 115 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | import { DuplexOptions } from 'stream' 2 | import { CommandFactory } from './shell' 3 | 4 | export type Options = Partial<{ 5 | cwd: string 6 | env: Record 7 | extends: CommandFactory 8 | timeout: number 9 | stream: DuplexOptions 10 | }> 11 | 12 | export const defaultOptions: Options = Object.freeze({ 13 | env: undefined, 14 | cwd: undefined, 15 | timeout: undefined, 16 | }) 17 | -------------------------------------------------------------------------------- /src/process.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess } from 'child_process' 2 | 3 | export type Process = Pick< 4 | ChildProcess, 5 | 'pid' | 'spawnfile' | 'spawnargs' | 'on' | 'stdin' | 'stdout' | 'stderr' 6 | > 7 | -------------------------------------------------------------------------------- /src/result.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from './command' 2 | 3 | export interface CommandResult { 4 | status: number 5 | command: Command 6 | } 7 | -------------------------------------------------------------------------------- /src/shell.ts: -------------------------------------------------------------------------------- 1 | import deepmerge from 'deepmerge' 2 | import { Command } from './command' 3 | import type { Options } from './options' 4 | import type { Process } from './process' 5 | 6 | const kOptions = Symbol('tish.internal.commandFactoryOptions') 7 | 8 | export interface CommandFactory { 9 | (command: string, options?: Options): Command 10 | (command: string, args: Array, options?: Options): Command 11 | (command: Process, options?: Options): Command 12 | [kOptions]?: Options 13 | } 14 | 15 | export function shell(shellOptions: Partial, extend?: CommandFactory): CommandFactory { 16 | if (extend?.[kOptions]) { 17 | shellOptions = deepmerge(extend[kOptions]!, shellOptions) 18 | } 19 | 20 | const $: CommandFactory = ( 21 | command: string | Process, 22 | ...args: [Options?] | [Array, Options?] 23 | ) => { 24 | const userOptions = args.pop() as Options 25 | const commandArgs = args.pop() as Array 26 | 27 | const options = { ...shellOptions, ...userOptions } 28 | 29 | if (commandArgs && typeof command === 'string') { 30 | for (const arg of commandArgs) { 31 | command += ' ' + arg 32 | } 33 | } 34 | 35 | return new Command(command, options) 36 | } 37 | 38 | $[kOptions] = shellOptions 39 | 40 | return $ as CommandFactory 41 | } 42 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { Readable, PassThrough } from 'stream' 2 | import pump from 'pump' 3 | 4 | export function isNonNullable(value: T | null | undefined): value is T { 5 | return value !== null && value !== undefined 6 | } 7 | 8 | export function cloneReadable(readable: Readable): Readable { 9 | return pump(readable, new PassThrough()) as Readable 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outFile": "dist/index.d.ts", 4 | "module": "amd", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "target": "ES2015", 8 | "lib": ["ES2015"], 9 | "moduleResolution": "node", 10 | "rootDir": "src", 11 | "checkJs": true 12 | }, 13 | "include": ["src"], 14 | "exclude": ["dist"] 15 | } 16 | --------------------------------------------------------------------------------