├── .prettierrc.yaml ├── .gitignore ├── jest.config.js ├── .travis.yml ├── .eslintrc.json ├── tsconfig.json ├── gulpfile.ts ├── LICENSE ├── package.json ├── index.ts ├── README.md └── index.test.ts /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | semi: false 2 | singleQuote: true 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /npm-debug.log 3 | 4 | /coverage/ 5 | /lib/ 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | preset: 'ts-jest', 4 | testEnvironment: 'node' 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'node' 4 | - '10' 5 | after_script: 6 | - npm run coveralls 7 | sudo: false 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es6": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:jest/recommended", 10 | "prettier", 11 | "prettier/@typescript-eslint" 12 | ], 13 | "parserOptions": { 14 | "project": "tsconfig.json" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "declarationMap": true, 7 | "sourceMap": true, 8 | "outDir": "./lib", 9 | "importHelpers": true, 10 | 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | 17 | "esModuleInterop": true 18 | }, 19 | 20 | "include": ["*.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /gulpfile.ts: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp' 2 | import shell from './index' 3 | 4 | const files = ['*.ts'] 5 | 6 | gulp.task('build', shell.task('tsc')) 7 | 8 | gulp.task('test', shell.task('jest')) 9 | 10 | gulp.task( 11 | 'coveralls', 12 | gulp.series('test', shell.task('coveralls < ./coverage/lcov.info')) 13 | ) 14 | 15 | gulp.task('lint', shell.task('eslint ' + files.join(' '))) 16 | 17 | gulp.task('format', shell.task('prettier --write ' + files.join(' '))) 18 | 19 | gulp.task('default', gulp.series('build', 'test', 'lint', 'format')) 20 | 21 | gulp.task( 22 | 'watch', 23 | gulp.series('default', () => { 24 | gulp.watch(files, gulp.task('default')) 25 | }) 26 | ) 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Sun Zheng'an 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gulp-shell", 3 | "version": "0.8.0", 4 | "description": "A handy command line interface for gulp", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "files": [ 8 | "lib/index.d.ts", 9 | "lib/index.d.ts.map", 10 | "lib/index.js", 11 | "lib/index.js.map" 12 | ], 13 | "scripts": { 14 | "build": "gulp build", 15 | "test": "gulp test", 16 | "coveralls": "gulp coveralls", 17 | "prepare": "npm run build", 18 | "prepublishOnly": "npm test" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/sun-zheng-an/gulp-shell" 23 | }, 24 | "keywords": [ 25 | "gulpplugin", 26 | "gulp", 27 | "shell", 28 | "command" 29 | ], 30 | "author": "Sun Zheng'an", 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/sun-zheng-an/gulp-shell/issues" 34 | }, 35 | "homepage": "https://github.com/sun-zheng-an/gulp-shell", 36 | "devDependencies": { 37 | "@types/fancy-log": "^1.3.1", 38 | "@types/gulp": "^4.0.6", 39 | "@types/jest": "^25.1.2", 40 | "@types/lodash.template": "^4.4.6", 41 | "@types/node": "^13.7.1", 42 | "@types/through2": "^2.0.34", 43 | "@typescript-eslint/eslint-plugin": "^2.19.2", 44 | "@typescript-eslint/parser": "^2.19.2", 45 | "coveralls": "^3.0.9", 46 | "eslint": "^6.8.0", 47 | "eslint-config-prettier": "^6.10.0", 48 | "eslint-plugin-jest": "^23.7.0", 49 | "gulp": "^4.0.2", 50 | "jest": "^25.1.0", 51 | "prettier": "^1.19.1", 52 | "ts-jest": "^25.2.0", 53 | "ts-node": "^8.6.2", 54 | "typescript": "^3.7.5", 55 | "vinyl": "^2.2.0" 56 | }, 57 | "dependencies": { 58 | "chalk": "^3.0.0", 59 | "fancy-log": "^1.3.3", 60 | "lodash.template": "^4.5.0", 61 | "plugin-error": "^1.0.1", 62 | "through2": "^3.0.1", 63 | "tslib": "^1.10.0" 64 | }, 65 | "engines": { 66 | "node": ">=10.0.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import { spawn } from 'child_process' 3 | import fancyLog from 'fancy-log' 4 | import template from 'lodash.template' 5 | import * as path from 'path' 6 | import PluginError from 'plugin-error' 7 | import { obj as throughObj } from 'through2' 8 | import Vinyl from 'vinyl' 9 | 10 | const PLUGIN_NAME = 'gulp-shell' 11 | 12 | interface Options { 13 | cwd?: string 14 | env?: NodeJS.ProcessEnv 15 | shell?: true | string 16 | quiet?: boolean 17 | verbose?: boolean 18 | ignoreErrors?: boolean 19 | errorMessage?: string 20 | templateData?: object 21 | } 22 | 23 | const normalizeCommands = (commands: string | string[]): string[] => { 24 | if (typeof commands === 'string') { 25 | commands = [commands] 26 | } 27 | 28 | if (!Array.isArray(commands)) { 29 | throw new PluginError(PLUGIN_NAME, 'Missing commands') 30 | } 31 | 32 | return commands 33 | } 34 | 35 | const normalizeOptions = (options: Options = {}): Required => { 36 | const pathToBin = path.join(process.cwd(), 'node_modules', '.bin') 37 | /* istanbul ignore next */ 38 | const pathName = process.platform === 'win32' ? 'Path' : 'PATH' 39 | const newPath = pathToBin + path.delimiter + process.env[pathName] 40 | const env = { 41 | ...process.env, 42 | [pathName]: newPath, 43 | ...options.env 44 | } 45 | 46 | return { 47 | cwd: process.cwd(), 48 | env, 49 | shell: true, 50 | quiet: false, 51 | verbose: false, 52 | ignoreErrors: false, 53 | errorMessage: 54 | 'Command `<%= command %>` failed with exit code <%= error.code %>', 55 | templateData: {}, 56 | ...options 57 | } 58 | } 59 | 60 | const runCommand = ( 61 | command: string, 62 | options: Required, 63 | file: Vinyl | null 64 | ): Promise => { 65 | const context = { file, ...options.templateData } 66 | command = template(command)(context) 67 | 68 | if (options.verbose) { 69 | fancyLog(`${PLUGIN_NAME}:`, chalk.cyan(command)) 70 | } 71 | 72 | const child = spawn(command, { 73 | env: options.env, 74 | cwd: template(options.cwd)(context), 75 | shell: options.shell, 76 | stdio: options.quiet ? 'ignore' : 'inherit' 77 | }) 78 | 79 | return new Promise((resolve, reject) => { 80 | child.on('exit', code => { 81 | if (code === 0 || options.ignoreErrors) { 82 | return resolve() 83 | } 84 | 85 | const context = { 86 | command, 87 | file, 88 | error: { code }, 89 | ...options.templateData 90 | } 91 | 92 | const message = template(options.errorMessage)(context) 93 | 94 | reject(new PluginError(PLUGIN_NAME, message)) 95 | }) 96 | }) 97 | } 98 | 99 | const runCommands = async ( 100 | commands: string[], 101 | options: Required, 102 | file: Vinyl | null 103 | ): Promise => { 104 | for (const command of commands) { 105 | await runCommand(command, options, file) 106 | } 107 | } 108 | 109 | const shell = ( 110 | commands: string | string[], 111 | options?: Options 112 | ): NodeJS.ReadWriteStream => { 113 | const normalizedCommands = normalizeCommands(commands) 114 | const normalizedOptions = normalizeOptions(options) 115 | 116 | const stream = throughObj(function(file, _encoding, done) { 117 | runCommands(normalizedCommands, normalizedOptions, file) 118 | .then(() => { 119 | this.push(file) 120 | }) 121 | .catch(error => { 122 | this.emit('error', error) 123 | }) 124 | .finally(done) 125 | }) 126 | 127 | stream.resume() 128 | 129 | return stream 130 | } 131 | 132 | shell.task = (commands: string | string[], options?: Options) => (): Promise< 133 | void 134 | > => runCommands(normalizeCommands(commands), normalizeOptions(options), null) 135 | 136 | export = shell 137 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gulp-shell 2 | 3 | [![NPM version](https://img.shields.io/npm/v/gulp-shell.svg)](https://npmjs.org/package/gulp-shell) 4 | [![Build Status](https://img.shields.io/travis/sun-zheng-an/gulp-shell/master.svg)](https://travis-ci.org/sun-zheng-an/gulp-shell) 5 | [![Coveralls Status](https://img.shields.io/coveralls/sun-zheng-an/gulp-shell/master.svg)](https://coveralls.io/r/sun-zheng-an/gulp-shell) 6 | [![Dependency Status](https://img.shields.io/david/sun-zheng-an/gulp-shell.svg)](https://david-dm.org/sun-zheng-an/gulp-shell) 7 | [![Downloads](https://img.shields.io/npm/dm/gulp-shell.svg)](https://npmjs.org/package/gulp-shell) 8 | 9 | > A handy command line interface for gulp 10 | 11 | ## Installation 12 | 13 | ```shell 14 | npm install --save-dev gulp-shell 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```js 20 | const gulp = require('gulp') 21 | const shell = require('gulp-shell') 22 | 23 | gulp.task('example', () => { 24 | return gulp 25 | .src('*.js', { read: false }) 26 | .pipe(shell(['echo <%= file.path %>'])) 27 | }) 28 | ``` 29 | 30 | Or you can use this shorthand: 31 | 32 | ```js 33 | gulp.task('greet', shell.task('echo Hello, World!')) 34 | ``` 35 | 36 | You can find more examples in the [gulpfile](https://github.com/sun-zheng-an/gulp-shell/blob/master/gulpfile.ts) of this project. 37 | 38 | **WARNING**: Running commands like ~~`gulp.src('').pipe(shell('whatever'))`~~ is [considered as an anti-pattern](https://github.com/sun-zheng-an/gulp-shell/issues/55). **PLEASE DON'T DO THAT ANYMORE**. 39 | 40 | ## API 41 | 42 | ### shell(commands, options) or shell.task(commands, options) 43 | 44 | #### commands 45 | 46 | type: `string` or `Array` 47 | 48 | A command can be a [template][] which can be interpolated by some [file][] info (e.g. `file.path`). 49 | 50 | **WARNING**: [Using command templates can be extremely dangerous](https://github.com/sun-zheng-an/gulp-shell/issues/83). Don't shoot yourself in the foot by ~~passing arguments like `$(rm -rf $HOME)`~~. 51 | 52 | #### options.cwd 53 | 54 | type: `string` 55 | 56 | default: [`process.cwd()`](http://nodejs.org/api/process.html#process_process_cwd) 57 | 58 | Sets the current working directory for the command. This can be a [template][] which can be interpolated by some [file][] info (e.g. `file.path`). 59 | 60 | #### options.env 61 | 62 | type: `object` 63 | 64 | By default, all the commands will be executed in an environment with all the variables in [`process.env`](http://nodejs.org/api/process.html#process_process_env) and `PATH` prepended by `./node_modules/.bin` (allowing you to run executables in your Node's dependencies). 65 | 66 | You can override any environment variables with this option. 67 | 68 | For example, setting it to `{ PATH: process.env.PATH }` will reset the `PATH` if the default one brings your some troubles. 69 | 70 | #### options.shell 71 | 72 | type: `string` 73 | 74 | default: `/bin/sh` on UNIX, and `cmd.exe` on Windows 75 | 76 | Change it to `bash` if you like. 77 | 78 | #### options.quiet 79 | 80 | type: `boolean` 81 | 82 | default: `false` 83 | 84 | By default, it will print the command output. 85 | 86 | #### options.verbose 87 | 88 | type: `boolean` 89 | 90 | default: `false` 91 | 92 | Set to `true` to print the command(s) to stdout as they are executed 93 | 94 | #### options.ignoreErrors 95 | 96 | type: `boolean` 97 | 98 | default: `false` 99 | 100 | By default, it will emit an `error` event when the command finishes unsuccessfully. 101 | 102 | #### options.errorMessage 103 | 104 | type: `string` 105 | 106 | default: `` Command `<%= command %>` failed with exit code <%= error.code %> `` 107 | 108 | You can add a custom error message for when the command fails. 109 | This can be a [template][] which can be interpolated with the current `command`, some [file][] info (e.g. `file.path`) and some error info (e.g. `error.code`). 110 | 111 | #### options.templateData 112 | 113 | type: `object` 114 | 115 | The data that can be accessed in [template][]. 116 | 117 | [template]: http://lodash.com/docs#template 118 | [file]: https://github.com/wearefractal/vinyl 119 | 120 | ## Changelog 121 | 122 | Details changes for each release are documented in the [release notes](https://github.com/sun-zheng-an/gulp-shell/releases). 123 | -------------------------------------------------------------------------------- /index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint jest/expect-expect: ["error", { "assertFunctionNames": ["expect", "expectToFlow"] }] */ 2 | 3 | import { join } from 'path' 4 | import Vinyl from 'vinyl' 5 | 6 | import shell from './index' 7 | 8 | const expectToFlow = ( 9 | stream: NodeJS.ReadWriteStream, 10 | done: (error?: unknown) => void 11 | ): void => { 12 | stream.on('error', done).on('data', () => { 13 | done() 14 | }) 15 | } 16 | 17 | describe('gulp-shell(commands, options)', () => { 18 | const fakeFile = new Vinyl({ 19 | cwd: __dirname, 20 | base: __dirname, 21 | path: join(__dirname, 'test-file') 22 | }) 23 | 24 | it('throws when `commands` is missing', () => { 25 | expect(shell).toThrow('Missing commands') 26 | }) 27 | 28 | it('works when `commands` is a string', () => { 29 | expect(shell.bind(null, 'true')).not.toThrow() 30 | }) 31 | 32 | it('passes file through', () => 33 | new Promise(resolve => { 34 | const stream = shell(['true']) 35 | 36 | stream.on('data', file => { 37 | expect(file).toBe(fakeFile) 38 | resolve() 39 | }) 40 | 41 | stream.write(fakeFile) 42 | })) 43 | 44 | it('executes command after interpolation', () => 45 | new Promise(resolve => { 46 | const stream = shell([`test <%= file.path %> = ${fakeFile.path}`]) 47 | 48 | expectToFlow(stream, resolve) 49 | 50 | stream.write(fakeFile) 51 | })) 52 | 53 | it('prepends `./node_modules/.bin` to `PATH`', () => 54 | new Promise(resolve => { 55 | const stream = shell( 56 | [`echo $PATH | grep -q "${join(process.cwd(), 'node_modules/.bin')}"`], 57 | { shell: 'bash' } 58 | ) 59 | 60 | expectToFlow(stream, resolve) 61 | 62 | stream.write(fakeFile) 63 | })) 64 | 65 | describe('.task(commands, options)', () => { 66 | it('returns a function which returns a promise', () => 67 | new Promise(resolve => { 68 | const task = shell.task(['echo hello world']) 69 | const promise = task() 70 | 71 | expect(promise).toBeInstanceOf(Promise) 72 | 73 | promise.then(resolve) 74 | })) 75 | }) 76 | 77 | describe('options', () => { 78 | describe('cwd', () => { 79 | it('sets the current working directory when `cwd` is a string', () => 80 | new Promise(resolve => { 81 | const stream = shell([`test $PWD = ${join(__dirname, '..')}`], { 82 | cwd: '..' 83 | }) 84 | 85 | expectToFlow(stream, resolve) 86 | 87 | stream.write(fakeFile) 88 | })) 89 | 90 | it('uses the process current working directory when `cwd` is not passed', () => 91 | new Promise(resolve => { 92 | const stream = shell([`test $PWD = ${__dirname}`]) 93 | 94 | expectToFlow(stream, resolve) 95 | 96 | stream.write(fakeFile) 97 | })) 98 | }) 99 | 100 | describe('shell', () => { 101 | it('changes the shell', () => 102 | new Promise(resolve => { 103 | const stream = shell(['[[ $0 = bash ]]'], { shell: 'bash' }) 104 | 105 | expectToFlow(stream, resolve) 106 | 107 | stream.write(fakeFile) 108 | })) 109 | }) 110 | 111 | describe('quiet', () => { 112 | it("won't output anything when `quiet` == true", () => 113 | new Promise(resolve => { 114 | const stream = shell(['echo cannot see me!'], { quiet: true }) 115 | 116 | expectToFlow(stream, resolve) 117 | 118 | stream.write(fakeFile) 119 | })) 120 | }) 121 | 122 | describe('verbose', () => { 123 | it('prints the command', () => 124 | new Promise(resolve => { 125 | const stream = shell(['echo you can see me twice'], { 126 | verbose: true 127 | }) 128 | 129 | expectToFlow(stream, resolve) 130 | 131 | stream.write(fakeFile) 132 | })) 133 | }) 134 | 135 | describe('ignoreErrors', () => { 136 | it('emits error by default', () => 137 | new Promise(resolve => { 138 | const stream = shell(['false']) 139 | 140 | stream.on('error', error => { 141 | expect(error).not.toBeUndefined() 142 | resolve() 143 | }) 144 | 145 | stream.write(fakeFile) 146 | })) 147 | 148 | it("won't emit error when `ignoreErrors` == true", () => 149 | new Promise((resolve, reject) => { 150 | const stream = shell(['false'], { ignoreErrors: true }) 151 | 152 | stream.on('error', reject) 153 | 154 | stream.on('data', data => { 155 | expect(data).toBe(fakeFile) 156 | resolve() 157 | }) 158 | 159 | stream.write(fakeFile) 160 | })) 161 | }) 162 | 163 | describe('errorMessage', () => { 164 | it('allows for custom messages', () => 165 | new Promise(resolve => { 166 | const errorMessage = 'foo' 167 | const stream = shell(['false'], { errorMessage }) 168 | 169 | stream.on('error', error => { 170 | expect(error.message).toBe(errorMessage) 171 | resolve() 172 | }) 173 | 174 | stream.write(fakeFile) 175 | })) 176 | 177 | it('includes the error object in the error context', () => 178 | new Promise(resolve => { 179 | const errorMessage = 'Foo <%= error.code %>' 180 | const expectedMessage = 'Foo 2' 181 | const stream = shell(['exit 2'], { errorMessage }) 182 | 183 | stream.on('error', error => { 184 | expect(error.message).toBe(expectedMessage) 185 | resolve() 186 | }) 187 | 188 | stream.write(fakeFile) 189 | })) 190 | }) 191 | }) 192 | }) 193 | --------------------------------------------------------------------------------