├── .editorconfig ├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── babel.config.js ├── package.json ├── src └── index.js ├── test ├── examples.js ├── quote-empty-strings.js ├── require.js ├── shell.escape.js ├── shell.js └── shell.preserve.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://EditorConfig.org 2 | 3 | # is this the topmost EditorConfig file? 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespaces = true 11 | 12 | [*.js] 13 | indent_size = 4 14 | indent_style = space 15 | max_line_length = 80 16 | 17 | [*.{yml,yaml}] 18 | indent_size = 2 19 | indent_style = space 20 | max_line_length = 80 21 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | # conditions are ORed together, so we need to fail the branch match to only 4 | # trigger on tag changes 5 | on: 6 | push: 7 | branches: 8 | - '!*' 9 | tags: 10 | - 'v*' 11 | 12 | env: 13 | CI: true 14 | 15 | jobs: 16 | publish: 17 | name: 'Publish to NPM' 18 | runs-on: 'ubuntu-latest' 19 | 20 | steps: 21 | - name: checkout 22 | uses: actions/checkout@v2 23 | 24 | - name: set up node 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: 14 28 | 29 | - name: install 30 | run: yarn install --prefer-offline 31 | 32 | - name: test 33 | run: yarn run prepublishOnly 34 | 35 | - uses: JS-DevTools/npm-publish@0f451a94170d1699fd50710966d48fb26194d939 36 | with: 37 | access: public 38 | token: ${{ secrets.NPM_TOKEN }} 39 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | paths-ignore: 7 | - '**.md' 8 | 9 | env: 10 | CI: true 11 | 12 | jobs: 13 | skip-duplicate-runs: 14 | runs-on: ubuntu-latest 15 | outputs: 16 | should_skip: ${{ steps.skip_check.outputs.should_skip }} 17 | steps: 18 | - id: skip_check 19 | uses: fkirc/skip-duplicate-actions@ea548f2a2a08c55dcb111418ca62181f7887e297 20 | with: 21 | concurrent_skipping: 'same_content' 22 | paths_ignore: '["**/*.md"]' 23 | 24 | test: 25 | needs: skip-duplicate-runs 26 | if: ${{ needs.skip-duplicate-runs.outputs.should_skip != 'true' }} 27 | name: 'node v${{ matrix.node-version }} on ${{ matrix.os }}' 28 | runs-on: ${{ matrix.os }} 29 | 30 | strategy: 31 | matrix: 32 | os: ['ubuntu-latest', 'macos-latest'] 33 | node-version: [10, 12, 14] 34 | 35 | steps: 36 | - name: checkout 37 | uses: actions/checkout@v2 38 | 39 | - name: set up node v${{ matrix.node-version }} 40 | uses: actions/setup-node@v1 41 | with: 42 | node-version: ${{ matrix.node-version }} 43 | 44 | - name: get cache directory 45 | id: get-yarn-cache 46 | run: echo "::set-output name=dir::$(yarn cache dir)" # XXX don't single quote 47 | 48 | - name: restore cache directory 49 | id: restore-yarn-cache 50 | uses: actions/cache@v2 51 | with: 52 | path: ${{ steps.get-yarn-cache.outputs.dir }} 53 | key: ${{ matrix.os }}-yarn-${{ hashFiles('yarn.lock') }} 54 | restore-keys: ${{ matrix.os }}-yarn- 55 | 56 | - name: install 57 | run: yarn install --prefer-offline --frozen-lockfile 58 | 59 | - name: test 60 | run: yarn test 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/node 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 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 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (https://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # TypeScript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | 64 | # parcel-bundler cache (https://parceljs.org/) 65 | .cache 66 | 67 | # next.js build output 68 | .next 69 | 70 | # nuxt.js build output 71 | .nuxt 72 | 73 | # vuepress build output 74 | .vuepress/dist 75 | 76 | # Serverless directories 77 | .serverless 78 | 79 | # End of https://www.gitignore.io/api/node 80 | 81 | # Custom 82 | 83 | /.*cache* 84 | /dev/ 85 | /dist/ 86 | /publish/ 87 | /staging/ 88 | /target/ 89 | temp.* 90 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 3.0.0 - TBD 2 | 3 | - fix [#GH-12](https://github.com/chocolateboy/shell-escape-tag/issues/12) - thanks, Ada Cohen 4 | 5 | Correctly quote empty strings on Unix (shell). This is a bug fix rather than a 6 | breaking change, but released as a new major version in case anyone was relying 7 | on the broken behavior, which has been there since the start. 8 | 9 | ### before 10 | 11 | ```javascript 12 | const pattern = '' 13 | shell`echo foo | grep ${pattern}` // => echo foo | grep 14 | ``` 15 | 16 | ### after 17 | 18 | ```javascript 19 | const pattern = '' 20 | shell`echo foo | grep ${pattern}` // => echo foo | grep '' 21 | ``` 22 | 23 | - update dependencies 24 | 25 | ## 2.0.2 - 2019-11-17 26 | 27 | - add tests for the examples in the README 28 | - doc tweaks 29 | 30 | ## 2.0.1 - 2019-11-15 31 | 32 | - remove unused dependency 33 | - doc tweaks 34 | 35 | ## 2.0.0 - 2019-11-15 36 | 37 | - **Breaking change**: 38 | - remove the `require("shell-escape-tag").default` shim 39 | (just use `require("shell-escape-tag")` (or `import`)) 40 | - bump the minimum compatibility level to 41 | [maintained Node.js versions](https://github.com/nodejs/Release#readme) 42 | (and compatible browsers) 43 | - update dependencies 44 | 45 | ## 1.2.2 - 2018-08-21 46 | 47 | - doc tweaks 48 | - update dependencies 49 | 50 | ## 1.2.1 - 2018-05-18 51 | 52 | - fix documentation error 53 | 54 | ## 1.2.0 - 2018-05-18 55 | 56 | - code: 57 | 58 | - allow (but don't require) the module to be required without 59 | the unsightly .default detour i.e. both of these now work: 60 | - require("shell-escape-tag").default 61 | - require("shell-escape-tag") 62 | - fix warning in node v6.6.0 and above by using 63 | the new inspect-hook API 64 | 65 | - build: 66 | 67 | - migrate from Babel 6 to Babel 7 68 | - migrate from Mocha to AVA 69 | - update dependencies 70 | 71 | ## 1.1.0 - 2017-01-02 72 | 73 | - build: 74 | 75 | - migrate Babel 5 to Babel 6 76 | - migrate Grunt to Gulp 4 77 | - migrate npm to yarn + lockfile 78 | - update dependencies 79 | 80 | - code: 81 | 82 | - add additional tests for null/undefined pruning 83 | - re-remove lodash.isarray (thanks, TehShrike) 84 | 85 | - changelog: 86 | 87 | - add timestamps 88 | - fix formatting 89 | 90 | ## 1.0.2 - 2016-06-17 91 | 92 | - reinstate lodash.isarray for backwards compatibility 93 | - doc tweaks 94 | 95 | ## 1.0.1 - 2016-06-17 96 | 97 | - narrow the scope of the lodash import (TehShrike) 98 | 99 | ## 1.0.0 - 2015-09-14 100 | 101 | - add changelog to package files 102 | 103 | ## 0.0.2 - 2015-09-14 104 | 105 | - add `.travis.yml` 106 | - add changelog 107 | - add Travis and NPM badges 108 | 109 | ## 0.0.1 - 2015-02-16 110 | 111 | - initial release 112 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The Artistic License 2.0 2 | ======================== 3 | 4 | _Copyright © 2000-2006, The Perl Foundation._ 5 | 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | ### Preamble 10 | 11 | This license establishes the terms under which a given free software 12 | Package may be copied, modified, distributed, and/or redistributed. 13 | The intent is that the Copyright Holder maintains some artistic 14 | control over the development of that Package while still keeping the 15 | Package available as open source and free software. 16 | 17 | You are always permitted to make arrangements wholly outside of this 18 | license directly with the Copyright Holder of a given Package. If the 19 | terms of this license do not permit the full use that you propose to 20 | make of the Package, you should contact the Copyright Holder and seek 21 | a different licensing arrangement. 22 | 23 | ### Definitions 24 | 25 | “Copyright Holder” means the individual(s) or organization(s) 26 | named in the copyright notice for the entire Package. 27 | 28 | “Contributor” means any party that has contributed code or other 29 | material to the Package, in accordance with the Copyright Holder's 30 | procedures. 31 | 32 | “You” and “your” means any person who would like to copy, 33 | distribute, or modify the Package. 34 | 35 | “Package” means the collection of files distributed by the 36 | Copyright Holder, and derivatives of that collection and/or of 37 | those files. A given Package may consist of either the Standard 38 | Version, or a Modified Version. 39 | 40 | “Distribute” means providing a copy of the Package or making it 41 | accessible to anyone else, or in the case of a company or 42 | organization, to others outside of your company or organization. 43 | 44 | “Distributor Fee” means any fee that you charge for Distributing 45 | this Package or providing support for this Package to another 46 | party. It does not mean licensing fees. 47 | 48 | “Standard Version” refers to the Package if it has not been 49 | modified, or has been modified only in ways explicitly requested 50 | by the Copyright Holder. 51 | 52 | “Modified Version” means the Package, if it has been changed, and 53 | such changes were not explicitly requested by the Copyright 54 | Holder. 55 | 56 | “Original License” means this Artistic License as Distributed with 57 | the Standard Version of the Package, in its current version or as 58 | it may be modified by The Perl Foundation in the future. 59 | 60 | “Source” form means the source code, documentation source, and 61 | configuration files for the Package. 62 | 63 | “Compiled” form means the compiled bytecode, object code, binary, 64 | or any other form resulting from mechanical transformation or 65 | translation of the Source form. 66 | 67 | ### Permission for Use and Modification Without Distribution 68 | 69 | **(1)** You are permitted to use the Standard Version and create and use 70 | Modified Versions for any purpose without restriction, provided that 71 | you do not Distribute the Modified Version. 72 | 73 | ### Permissions for Redistribution of the Standard Version 74 | 75 | **(2)** You may Distribute verbatim copies of the Source form of the 76 | Standard Version of this Package in any medium without restriction, 77 | either gratis or for a Distributor Fee, provided that you duplicate 78 | all of the original copyright notices and associated disclaimers. At 79 | your discretion, such verbatim copies may or may not include a 80 | Compiled form of the Package. 81 | 82 | **(3)** You may apply any bug fixes, portability changes, and other 83 | modifications made available from the Copyright Holder. The resulting 84 | Package will still be considered the Standard Version, and as such 85 | will be subject to the Original License. 86 | 87 | ### Distribution of Modified Versions of the Package as Source 88 | 89 | **(4)** You may Distribute your Modified Version as Source (either gratis 90 | or for a Distributor Fee, and with or without a Compiled form of the 91 | Modified Version) provided that you clearly document how it differs 92 | from the Standard Version, including, but not limited to, documenting 93 | any non-standard features, executables, or modules, and provided that 94 | you do at least ONE of the following: 95 | 96 | * **(a)** make the Modified Version available to the Copyright Holder 97 | of the Standard Version, under the Original License, so that the 98 | Copyright Holder may include your modifications in the Standard 99 | Version. 100 | * **(b)** ensure that installation of your Modified Version does not 101 | prevent the user installing or running the Standard Version. In 102 | addition, the Modified Version must bear a name that is different 103 | from the name of the Standard Version. 104 | * **(c)** allow anyone who receives a copy of the Modified Version to 105 | make the Source form of the Modified Version available to others 106 | under 107 | * **(i)** the Original License or 108 | * **(ii)** a license that permits the licensee to freely copy, 109 | modify and redistribute the Modified Version using the same 110 | licensing terms that apply to the copy that the licensee 111 | received, and requires that the Source form of the Modified 112 | Version, and of any works derived from it, be made freely 113 | available in that license fees are prohibited but Distributor 114 | Fees are allowed. 115 | 116 | ### Distribution of Compiled Forms of the Standard Version 117 | ### or Modified Versions without the Source 118 | 119 | **(5)** You may Distribute Compiled forms of the Standard Version without 120 | the Source, provided that you include complete instructions on how to 121 | get the Source of the Standard Version. Such instructions must be 122 | valid at the time of your distribution. If these instructions, at any 123 | time while you are carrying out such distribution, become invalid, you 124 | must provide new instructions on demand or cease further distribution. 125 | If you provide valid instructions or cease distribution within thirty 126 | days after you become aware that the instructions are invalid, then 127 | you do not forfeit any of your rights under this license. 128 | 129 | **(6)** You may Distribute a Modified Version in Compiled form without 130 | the Source, provided that you comply with Section 4 with respect to 131 | the Source of the Modified Version. 132 | 133 | ### Aggregating or Linking the Package 134 | 135 | **(7)** You may aggregate the Package (either the Standard Version or 136 | Modified Version) with other packages and Distribute the resulting 137 | aggregation provided that you do not charge a licensing fee for the 138 | Package. Distributor Fees are permitted, and licensing fees for other 139 | components in the aggregation are permitted. The terms of this license 140 | apply to the use and Distribution of the Standard or Modified Versions 141 | as included in the aggregation. 142 | 143 | **(8)** You are permitted to link Modified and Standard Versions with 144 | other works, to embed the Package in a larger work of your own, or to 145 | build stand-alone binary or bytecode versions of applications that 146 | include the Package, and Distribute the result without restriction, 147 | provided the result does not expose a direct interface to the Package. 148 | 149 | ### Items That are Not Considered Part of a Modified Version 150 | 151 | **(9)** Works (including, but not limited to, modules and scripts) that 152 | merely extend or make use of the Package, do not, by themselves, cause 153 | the Package to be a Modified Version. In addition, such works are not 154 | considered parts of the Package itself, and are not subject to the 155 | terms of this license. 156 | 157 | ### General Provisions 158 | 159 | **(10)** Any use, modification, and distribution of the Standard or 160 | Modified Versions is governed by this Artistic License. By using, 161 | modifying or distributing the Package, you accept this license. Do not 162 | use, modify, or distribute the Package, if you do not accept this 163 | license. 164 | 165 | **(11)** If your Modified Version has been derived from a Modified 166 | Version made by someone other than you, you are nevertheless required 167 | to ensure that your Modified Version complies with the requirements of 168 | this license. 169 | 170 | **(12)** This license does not grant you the right to use any trademark, 171 | service mark, tradename, or logo of the Copyright Holder. 172 | 173 | **(13)** This license includes the non-exclusive, worldwide, 174 | free-of-charge patent license to make, have made, use, offer to sell, 175 | sell, import and otherwise transfer the Package with respect to any 176 | patent claims licensable by the Copyright Holder that are necessarily 177 | infringed by the Package. If you institute patent litigation 178 | (including a cross-claim or counterclaim) against any party alleging 179 | that the Package constitutes direct or contributory patent 180 | infringement, then this Artistic License to you shall terminate on the 181 | date that such litigation is filed. 182 | 183 | **(14)** **Disclaimer of Warranty:** 184 | 185 | THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS "AS 186 | IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES. THE IMPLIED 187 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR 188 | NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY YOUR LOCAL 189 | LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR CONTRIBUTOR WILL 190 | BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 191 | DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE, EVEN IF 192 | ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 193 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/chocolateboy/shell-escape-tag/workflows/test/badge.svg)](https://github.com/chocolateboy/shell-escape-tag/actions?query=workflow%3Atest) 2 | [![NPM Version](https://img.shields.io/npm/v/shell-escape-tag.svg)](https://www.npmjs.org/package/shell-escape-tag) 3 | 4 | 5 | 6 | - [NAME](#name) 7 | - [INSTALL](#install) 8 | - [SYNOPSIS](#synopsis) 9 | - [DESCRIPTION](#description) 10 | - [EXPORTS](#exports) 11 | - [shell (default)](#shell-default) 12 | - [FUNCTIONS](#functions) 13 | - [shell.escape](#shellescape) 14 | - [shell.preserve](#shellpreserve) 15 | - [DEVELOPMENT](#development) 16 | - [COMPATIBILITY](#compatibility) 17 | - [SEE ALSO](#see-also) 18 | - [VERSION](#version) 19 | - [AUTHOR](#author) 20 | - [COPYRIGHT AND LICENSE](#copyright-and-license) 21 | 22 | 23 | 24 | # NAME 25 | 26 | shell-escape-tag - an ES6 template tag which escapes parameters for interpolation into shell commands 27 | 28 | # INSTALL 29 | 30 | $ npm install shell-escape-tag 31 | 32 | # SYNOPSIS 33 | 34 | ```javascript 35 | import shell from 'shell-escape-tag' 36 | 37 | let filenames = glob('Holiday Snaps/*.jpg') 38 | let title = 'Holiday Snaps' 39 | let command = shell`compress --title ${title} ${filenames}` 40 | 41 | console.log(command) // compress --title 'Holiday Snaps' 'Holiday Snaps/Pic 1.jpg' 'Holiday Snaps/Pic 2.jpg' 42 | ``` 43 | 44 | # DESCRIPTION 45 | 46 | This module exports an ES6 tagged-template function which escapes (i.e. quotes) 47 | its parameters for safe inclusion in shell commands. Parameters can be strings, 48 | arrays of strings, or nested arrays of strings, arrays and already-processed 49 | parameters. 50 | 51 | The exported function also provides two helper functions which respectively 52 | [escape](#shellescape) and [preserve](#shellpreserve) their parameters and protect them 53 | from further processing. 54 | 55 | # EXPORTS 56 | 57 | ## shell (default) 58 | 59 | **Signature**: (template: string) → string 60 | 61 | ```javascript 62 | import shell from 'shell-escape-tag' 63 | 64 | let filenames = ['foo bar', "baz's quux"] 65 | let title = 'My Title' 66 | let command = shell`command --title ${title} ${filenames}` 67 | 68 | console.log(command) // command --title 'My Title' 'foo bar' 'baz'"'"'s quux' 69 | ``` 70 | 71 | Takes a [template literal](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals) 72 | and escapes any interpolated parameters. `null` and `undefined` values are 73 | ignored. Arrays are flattened and their elements are escaped and joined with a 74 | space. All other values are stringified i.e. `false` is mapped to `"false"` 75 | etc. Parameters that have been escaped with [`shell.escape`](#shellescape) or 76 | preserved with [`shell.preserve`](#shellpreserve) are passed through verbatim. 77 | 78 | # FUNCTIONS 79 | 80 | ## shell.escape 81 | 82 | **Signature**: (...args: any[]) → object 83 | 84 | ```javascript 85 | import shell from 'shell-escape-tag' 86 | 87 | let escaped = shell.escape("foo's bar") 88 | let command1 = `command ${escaped}` 89 | let command2 = shell`command ${escaped}` 90 | 91 | console.log(command1) // command 'foo'"'"'s bar' 92 | console.log(command2) // command 'foo'"'"'s bar' 93 | ``` 94 | 95 | Flattens, compacts and escapes any parameters which haven't already been 96 | escaped or preserved, joins the resulting elements with a space, and wraps the 97 | resulting string in an object which remains escaped when embedded in a template 98 | or passed as a direct or nested parameter to [`shell`](#shell-default), 99 | [`shell.escape`](#shellescape), or [`shell.preserve`](#shellpreserve). 100 | 101 | ## shell.preserve 102 | 103 | **Aliases**: protect, verbatim 104 | 105 | **Signature**: (...args: any[]) → object 106 | 107 | ```javascript 108 | import shell from 'shell-escape-tag' 109 | 110 | let preserved = shell.preserve("baz's quux") 111 | let command1 = `command "${preserved}"` 112 | let command2 = shell`command "${preserved}"` 113 | 114 | console.log(command1) // command "baz's quux" 115 | console.log(command2) // command "baz's quux" 116 | ``` 117 | 118 | Flattens, compacts and preserves any parameters which haven't already been 119 | escaped or preserved, joins the resulting elements with a space, and wraps the 120 | resulting string in an object which is passed through verbatim when embedded in 121 | a template or passed as a direct or nested parameter to 122 | [`shell`](#shell-default), [`shell.escape`](#shellescape), or 123 | [`shell.preserve`](#shellpreserve). 124 | 125 | # DEVELOPMENT 126 | 127 |
128 | 129 | 130 | ## NPM Scripts 131 | 132 | The following NPM scripts are available: 133 | 134 | - build - compile the code and save it to the `dist` directory 135 | - build:doc - generate the README's TOC (table of contents) 136 | - clean - remove the `dist` directory and other build artifacts 137 | - rebuild - clean the build artifacts and recompile the code 138 | - test - clean and rebuild and run the test suite 139 | - test:run - run the test suite 140 | 141 |
142 | 143 | # COMPATIBILITY 144 | 145 | - [Maintained Node.js versions](https://github.com/nodejs/Release#readme) (and compatible browsers) 146 | 147 | # SEE ALSO 148 | 149 | - [any-shell-escape](https://www.npmjs.com/package/any-shell-escape) - escape and stringify an array of arguments to be executed on the shell 150 | - [execa](https://www.npmjs.com/package/execa) - a better `child_process` 151 | - [@perl/qw](https://www.npmjs.com/package/@perl/qw) - a template tag for quoted word literals like Perl's `qw(...)` 152 | - [@perl/qx](https://www.npmjs.com/package/@perl/qx) - a template tag to run a command and capture its output like Perl's `qx(...)` 153 | - [puka](https://www.npmjs.com/package/puka) - a cross-platform library for safely passing strings through shells 154 | 155 | # VERSION 156 | 157 | 2.0.2 158 | 159 | # AUTHOR 160 | 161 | [chocolateboy](mailto:chocolate@cpan.org) 162 | 163 | # COPYRIGHT AND LICENSE 164 | 165 | Copyright © 2015-2020 by chocolateboy. 166 | 167 | This is free software; you can redistribute it and/or modify it under the terms 168 | of the [Artistic License 2.0](https://www.opensource.org/licenses/artistic-license-2.0.php). 169 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | 'bili/babel', 4 | ], 5 | 6 | plugins: [], 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shell-escape-tag", 3 | "author": "chocolateboy", 4 | "version": "2.0.2", 5 | "description": "An ES6 template tag which escapes parameters for interpolation into shell commands", 6 | "repository": "chocolateboy/shell-escape-tag", 7 | "license": "Artistic-2.0", 8 | "main": "dist/index.js", 9 | "module": "dist/index.esm.js", 10 | "scripts": { 11 | "build": "bili --quiet --map --format cjs -d dist src/index.js", 12 | "build:doc": "toc-md --max-depth 3 README.md", 13 | "build:prod": "cross-env NODE_ENV=production bili --format cjs --format esm -d dist src/index.js", 14 | "clean": "shx rm -rf dist", 15 | "prepublishOnly": "run-s clean build:prod build:doc test:unit", 16 | "rebuild": "run-s clean build", 17 | "repl": "node -r ./dev/repl.js", 18 | "test": "run-s build test:unit", 19 | "test:unit": "ava --verbose" 20 | }, 21 | "files": [ 22 | "dist/index.esm.js", 23 | "dist/index.js" 24 | ], 25 | "browserslist": "maintained node versions", 26 | "dependencies": { 27 | "any-shell-escape": "chocolateboy/any-shell-escape#quote-empty-strings", 28 | "inspect-custom-symbol": "^1.1.1", 29 | "just-flatten-it": "^2.1.0", 30 | "just-zip-it": "^2.1.0" 31 | }, 32 | "devDependencies": { 33 | "ava": "^3.13.0", 34 | "bili": "^5.0.5", 35 | "cross-env": "^7.0.2", 36 | "npm-run-all": "^4.1.5", 37 | "shx": "^0.3.2", 38 | "toc-md-alt": "^0.4.1" 39 | }, 40 | "keywords": [ 41 | "es6", 42 | "escape", 43 | "interpolate", 44 | "interpolation", 45 | "quote", 46 | "shell", 47 | "tag", 48 | "tagged", 49 | "tagged-template", 50 | "tagged-templates", 51 | "template", 52 | "templates" 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import shellEscape from 'any-shell-escape' 2 | import INSPECT from 'inspect-custom-symbol' 3 | import flatten from 'just-flatten-it' 4 | import zip from 'just-zip-it' 5 | 6 | /* 7 | * wrapper class for already escaped/preserved values. the `shell` function 8 | * extracts the escaped/preserved value from instances of this class rather than 9 | * escaping them again 10 | */ 11 | class Escaped { 12 | constructor (value) { 13 | this.value = value 14 | } 15 | 16 | toString () { 17 | return this.value 18 | } 19 | 20 | // for console.log etc. 21 | [INSPECT] () { 22 | return this.value 23 | } 24 | } 25 | 26 | /* 27 | * performs the following mappings on the supplied value(s): 28 | * 29 | * - already-escaped/preserved values are passed through verbatim 30 | * - null and undefined are ignored (i.e. mapped to empty arrays, 31 | * which are pruned by flatten) 32 | * - non-strings are stringified e.g. false -> "false" 33 | * 34 | * then flattens the resulting array and returns its elements joined by a space 35 | */ 36 | function shellQuote (params, options = {}) { 37 | const escaped = [traverse(params, options)] 38 | // const flattened = escaped.flat(Infinity) // XXX not supported in Node.js v8 39 | const flattened = flatten(escaped) 40 | 41 | return flattened.join(' ') 42 | } 43 | 44 | /* 45 | * the (recursive) guts of `shellQuote`. returns a leaf node (string) or a 46 | * possibly-empty array of arrays/leaf nodes. prunes null and undefined by 47 | * returning empty arrays 48 | */ 49 | function traverse (params, options) { 50 | if (params instanceof Escaped) { 51 | return params.value 52 | } else if (Array.isArray(params)) { 53 | return params.map(param => traverse(param, options)) 54 | } else if (params == null) { 55 | return [] 56 | } else { 57 | return options.verbatim ? String(params) : shellEscape(String(params)) 58 | } 59 | } 60 | 61 | /* 62 | * escapes embedded string/array template parameters and passes through already 63 | * escaped/preserved parameters verbatim 64 | */ 65 | function shell (strings, ...params) { 66 | let result = '' 67 | 68 | for (const [string, param] of zip(strings, params)) { 69 | result += string + shellQuote(param) 70 | } 71 | 72 | return result 73 | } 74 | 75 | /* 76 | * helper function which escapes its parameters and prevents them from being 77 | * re-escaped 78 | */ 79 | shell.escape = function escape (...params) { 80 | return new Escaped(shellQuote(params, { verbatim: false })) 81 | } 82 | 83 | /* 84 | * helper function which protects its parameters from being escaped 85 | */ 86 | shell.preserve = function preserve (...params) { 87 | return new Escaped(shellQuote(params, { verbatim: true })) 88 | } 89 | 90 | shell.protect = shell.verbatim = shell.preserve 91 | 92 | export default shell 93 | -------------------------------------------------------------------------------- /test/examples.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const shell = require('..') 3 | 4 | // XXX results assume these tests are running on non-Windows 5 | 6 | test('synopsis', t => { 7 | const filenames = [1, 2].map(it => `Holiday Snaps/Pic ${it}.jpg`) 8 | const title = 'Holiday Snaps' 9 | const command = shell`compress --title ${title} ${filenames}` 10 | 11 | t.is(command, "compress --title 'Holiday Snaps' 'Holiday Snaps/Pic 1.jpg' 'Holiday Snaps/Pic 2.jpg'") 12 | }) 13 | 14 | test('escape', t => { 15 | const escaped = shell.escape("foo's bar") 16 | const command1 = `command ${escaped}` 17 | const command2 = shell`command ${escaped}` 18 | 19 | t.is(command1, `command 'foo'"'"'s bar'`) 20 | t.is(command2, `command 'foo'"'"'s bar'`) 21 | }) 22 | 23 | test('preserve', t => { 24 | const preserved = shell.preserve("baz's quux") 25 | const command1 = `command "${preserved}"` 26 | const command2 = shell`command "${preserved}"` 27 | 28 | t.is(command1, `command "baz's quux"`) 29 | t.is(command2, `command "baz's quux"`) 30 | }) 31 | -------------------------------------------------------------------------------- /test/quote-empty-strings.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const shell = require('..') 3 | 4 | // from https://github.com/chocolateboy/shell-escape-tag/issues/12 5 | 6 | test('grep', t => { 7 | const pattern = '' 8 | const command = shell`echo 'hello' | grep ${pattern}` 9 | 10 | t.is(command, "echo 'hello' | grep ''") 11 | }) 12 | 13 | test('shell', t => { 14 | const emptyString = '' 15 | const emptyStrings = ['foo', '', 'bar', '', 'baz'] 16 | const command = shell`command --test ${emptyString} ${emptyStrings}` 17 | 18 | t.is(command, "command --test '' foo '' bar '' baz") 19 | }) 20 | 21 | test('escape', t => { 22 | const emptyString = shell.escape('') 23 | const emptyStrings = shell.escape(['foo', '', 'bar', '', 'baz']) 24 | const command = shell`command --test ${emptyString} ${emptyStrings}` 25 | 26 | t.is(command, "command --test '' foo '' bar '' baz") 27 | }) 28 | 29 | test('preserve', t => { 30 | const emptyString = shell.preserve('') 31 | const emptyStrings = shell.preserve(['foo', '', 'bar', '', 'baz']) 32 | const command = shell`command --test ${emptyString} ${emptyStrings}` 33 | 34 | t.is(command, 'command --test foo bar baz') 35 | }) 36 | -------------------------------------------------------------------------------- /test/require.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | 3 | test('require works without .default', t => { 4 | const shell = require('..') 5 | const files = ['foo bar.gif', 'baz quux.png'] 6 | t.is(shell`compress ${files}`, "compress 'foo bar.gif' 'baz quux.png'") 7 | }) 8 | -------------------------------------------------------------------------------- /test/shell.escape.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const { inspect } = require('util') 3 | const shell = require('..') 4 | 5 | test('stringifies to the escaped string', t => { 6 | t.is(shell.escape('Foo Bar').toString(), "'Foo Bar'") 7 | t.is(inspect(shell.escape('Foo Bar')), "'Foo Bar'") 8 | }) 9 | 10 | test('escapes a string with spaces', t => { 11 | t.is(shell.escape('Foo Bar').value, "'Foo Bar'") 12 | }) 13 | 14 | test('escapes an array of strings with spaces', t => { 15 | t.is( 16 | shell.escape(['Foo Bar', 'Baz Quux']).value, 17 | "'Foo Bar' 'Baz Quux'" 18 | ) 19 | }) 20 | 21 | test('escapes a string with quotes', t => { 22 | t.is( 23 | shell.escape(`Foo's "Bar"`).value, 24 | `'Foo'"'"'s "Bar"'` 25 | ) 26 | }) 27 | 28 | test('escapes an array of strings with quotes', t => { 29 | t.is( 30 | shell.escape([`Foo's "Bar"`, `Foo 'Bar' "Baz"`]).value, 31 | `'Foo'"'"'s "Bar"' 'Foo '"'"'Bar'"'"' "Baz"'` 32 | ) 33 | }) 34 | 35 | test('ignores null and undefined', t => { 36 | t.is(shell.escape(null).value, '') 37 | t.is(shell.escape(undefined).value, '') 38 | }) 39 | 40 | test('ignores null and undefined array values', t => { 41 | t.is( 42 | shell.escape([ 43 | 'Foo Bar', 44 | null, 45 | undefined, 46 | 'Baz Quux' 47 | ]).value, 48 | `'Foo Bar' 'Baz Quux'` 49 | ) 50 | }) 51 | 52 | test('stringifies defined values', t => { 53 | t.is(shell.escape('').value, "''") 54 | t.is(shell.escape(false).value, 'false') 55 | t.is(shell.escape(42).value, '42') 56 | }) 57 | 58 | test('stringifies defined array values', t => { 59 | t.is( 60 | shell.escape([ 61 | 'Foo Bar', 62 | 0, 63 | '', 64 | false, 65 | 'Baz Quux' 66 | ]).value, 67 | `'Foo Bar' 0 '' false 'Baz Quux'` 68 | ) 69 | }) 70 | 71 | test('flattens nested array values', t => { 72 | t.is( 73 | shell.escape([ 74 | [ 'Foo Bar', 75 | [ 0, '', false, 76 | [ null, undefined, 77 | ['Baz Quux'] 78 | ] 79 | ] 80 | ] 81 | ]).value, 82 | `'Foo Bar' 0 '' false 'Baz Quux'` 83 | ) 84 | }) 85 | 86 | test("doesn't escape embedded escaped strings", t => { 87 | const escaped = shell.escape('Foo Bar') 88 | t.is(shell`foo ${escaped}`, "foo 'Foo Bar'") 89 | }) 90 | 91 | test("doesn't escape embedded escaped arrays", t => { 92 | const escaped = shell.escape(['Foo Bar', 'Baz Quux']) 93 | t.is(shell`foo ${escaped}`, "foo 'Foo Bar' 'Baz Quux'") 94 | }) 95 | 96 | test("doesn't escape embedded nested strings/arrays", t => { 97 | const escaped = shell.escape([ 98 | '1 2', 99 | shell.escape('3 4'), 100 | shell.escape(['5 6', '7 8']), 101 | '9 10' 102 | ]) 103 | 104 | t.is( 105 | shell`foo ${shell.escape(escaped)} bar`, 106 | "foo '1 2' '3 4' '5 6' '7 8' '9 10' bar" 107 | ) 108 | }) 109 | 110 | test('supports embedded preserves', t => { 111 | const param = shell.escape(shell.preserve('foo bar'), ["baz's quux"]) 112 | 113 | t.is( 114 | shell`command ${param}`, 115 | `command foo bar 'baz'"'"'s quux'` 116 | ) 117 | }) 118 | -------------------------------------------------------------------------------- /test/shell.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const shell = require('..') 3 | 4 | test('preserves strings with no interpolation', t => { 5 | t.is(shell``, '') 6 | t.is(shell` `, ' ') 7 | t.is(shell`\t`, '\t') 8 | t.is(shell`\n`, '\n') 9 | t.is(shell`\r`, '\r') 10 | t.is(shell`foo bar`, 'foo bar') 11 | t.is(shell`foo's bar`, `foo's bar`) 12 | }) 13 | 14 | test('ignores null values', t => { 15 | t.is(shell`foo${null}bar`, 'foobar') 16 | }) 17 | 18 | test('ignores undefined values', t => { 19 | t.is(shell`foo${undefined}bar`, 'foobar') 20 | }) 21 | 22 | test('ignores null and undefined values', t => { 23 | const bar = [null, undefined, 'bar', undefined, null] 24 | t.is(shell`foo${bar}baz`, 'foobarbaz') 25 | }) 26 | 27 | test('does not ignore empty strings', t => { 28 | const bar = ['', 'bar', ''] 29 | t.is(shell`foo ${bar} baz`, "foo '' bar '' baz") 30 | }) 31 | 32 | test('escapes a string which only contains an interpolation', t => { 33 | const foo = 'Foo' 34 | t.is(shell`${foo}`, 'Foo') 35 | }) 36 | 37 | test('escapes a string which only contains interpolations', t => { 38 | const foo = 'Foo' 39 | const bar = 'Bar' 40 | const baz = 'Baz' 41 | 42 | t.is(shell`${foo}${bar}${baz}`, 'FooBarBaz') 43 | }) 44 | 45 | test('escapes a string which starts with an interpolation', t => { 46 | const foo = 'Foo' 47 | 48 | t.is(shell`${foo}bar`, 'Foobar') 49 | t.is(shell`${foo} bar`, 'Foo bar') 50 | }) 51 | 52 | test('escapes a string which starts with interpolations', t => { 53 | const foo = 'Foo' 54 | const bar = 'Bar' 55 | const baz = 'Baz' 56 | 57 | t.is(shell`${foo}${bar}${baz}quux`, 'FooBarBazquux') 58 | t.is(shell`${foo} ${bar} ${baz} quux`, 'Foo Bar Baz quux') 59 | }) 60 | 61 | test('escapes a string which ends with an interpolation', t => { 62 | const foo = 'Foo' 63 | 64 | t.is(shell`foo${foo}`, 'fooFoo') 65 | t.is(shell`foo ${foo}`, 'foo Foo') 66 | }) 67 | 68 | test('escapes a string which ends with interpolations', t => { 69 | const foo = 'Foo' 70 | const bar = 'Bar' 71 | const baz = 'Baz' 72 | 73 | t.is(shell`foo${foo}${bar}${baz}`, 'fooFooBarBaz') 74 | t.is(shell`foo ${foo} ${bar} ${baz}`, 'foo Foo Bar Baz') 75 | }) 76 | 77 | test('escapes a string with spaces', t => { 78 | const foo = 'Foo Bar' 79 | t.is(shell`foo ${foo}`, "foo 'Foo Bar'") 80 | }) 81 | 82 | test('escapes an array of strings with spaces', t => { 83 | const foo = ['Foo Bar', 'Baz Quux'] 84 | t.is(shell`foo ${foo}`, "foo 'Foo Bar' 'Baz Quux'") 85 | }) 86 | 87 | test('escapes a string with quotes', t => { 88 | const foo = `Foo's 'Bar' "Baz"` 89 | 90 | t.is( 91 | shell`foo ${foo} bar`, 92 | `foo 'Foo'"'"'s '"'"'Bar'"'"' "Baz"' bar` 93 | ) 94 | }) 95 | 96 | test('escapes an array of strings with quotes', t => { 97 | const foo = [`Foo's "Bar"`, `Foo 'Bar' "Baz"`] 98 | 99 | t.is( 100 | shell`foo ${foo} bar`, 101 | `foo 'Foo'"'"'s "Bar"' 'Foo '"'"'Bar'"'"' "Baz"' bar` 102 | ) 103 | }) 104 | -------------------------------------------------------------------------------- /test/shell.preserve.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const { inspect } = require('util') 3 | const shell = require('..') 4 | 5 | test('is aliased to shell.protect', t => { 6 | t.is(shell.protect, shell.preserve) 7 | }) 8 | 9 | test('is aliased to shell.verbatim', t => { 10 | t.is(shell.verbatim, shell.preserve) 11 | }) 12 | 13 | test('stringifies to the unescaped string', t => { 14 | t.is(shell.preserve('Foo Bar').toString(), 'Foo Bar') 15 | t.is(inspect(shell.preserve('Foo Bar')), 'Foo Bar') 16 | }) 17 | 18 | test('preserves a string with spaces', t => { 19 | t.is(shell.preserve('Foo Bar').value, 'Foo Bar') 20 | }) 21 | 22 | test('preserves an array of strings with spaces', t => { 23 | t.is( 24 | shell.preserve(['Foo Bar', 'Baz Quux']).value, 25 | 'Foo Bar Baz Quux' 26 | ) 27 | }) 28 | 29 | test('preserves a string with quotes', t => { 30 | t.is( 31 | shell.preserve(`Foo's "Bar"`).value, 32 | `Foo's "Bar"` 33 | ) 34 | }) 35 | 36 | test('preserves an array of strings with quotes', t => { 37 | t.is( 38 | shell.preserve([`Foo's "Bar"`, `Foo 'Bar' "Baz"`]).value, 39 | `Foo's "Bar" Foo 'Bar' "Baz"` 40 | ) 41 | }) 42 | 43 | test('ignores null and undefined', t => { 44 | t.is(shell.preserve(null).value, '') 45 | t.is(shell.preserve(undefined).value, '') 46 | }) 47 | 48 | test('ignores null and undefined array values', t => { 49 | t.is( 50 | shell.preserve([ 51 | 'Foo Bar', 52 | null, 53 | undefined, 54 | 'Baz Quux' 55 | ]).value, 56 | `Foo Bar Baz Quux` 57 | ) 58 | }) 59 | 60 | test('stringifies defined values', t => { 61 | t.is(shell.preserve('').value, '') 62 | t.is(shell.preserve(false).value, 'false') 63 | t.is(shell.preserve(42).value, '42') 64 | }) 65 | 66 | test('stringifies defined array values', t => { 67 | t.is( 68 | shell.preserve([ 69 | 'Foo Bar', 70 | 0, 71 | '', 72 | false, 73 | 'Baz Quux' 74 | ]).value, 75 | `Foo Bar 0 false Baz Quux` 76 | ) 77 | }) 78 | 79 | test('flattens nested array values', t => { 80 | t.is( 81 | shell.preserve([ 82 | [ 'Foo Bar', 83 | [ 0, '', false, 84 | [ null, undefined, 85 | ['Baz Quux'] 86 | ] 87 | ] 88 | ] 89 | ]).value, 90 | `Foo Bar 0 false Baz Quux` 91 | ) 92 | }) 93 | 94 | test('preserves embedded strings', t => { 95 | const verbatim = shell.preserve('Foo Bar') 96 | t.is(shell`foo ${verbatim}`, 'foo Foo Bar') 97 | }) 98 | 99 | test('preserves embedded arrays', t => { 100 | const verbatim = shell.preserve(['Foo Bar', 'Baz Quux']) 101 | t.is(shell`foo ${verbatim}`, 'foo Foo Bar Baz Quux') 102 | }) 103 | 104 | test('preserves embedded nested strings/arrays', t => { 105 | const verbatim = shell.preserve([ 106 | '1 2', 107 | shell.preserve('3 4'), 108 | shell.preserve(['5 6', '7 8']), 109 | '9 10' 110 | ]) 111 | 112 | t.is( 113 | shell`foo ${shell.preserve(verbatim)} bar`, 114 | 'foo 1 2 3 4 5 6 7 8 9 10 bar' 115 | ) 116 | }) 117 | 118 | test('supports embedded escapes', t => { 119 | const param = shell.preserve(shell.escape('foo bar'), ["baz's quux"]) 120 | 121 | t.is( 122 | shell`command ${param}`, 123 | `command 'foo bar' baz's quux` 124 | ) 125 | }) 126 | --------------------------------------------------------------------------------