diff --git a/README.md b/README.md index e2a644a..b0b7373 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,32 @@ Install via npm: ```sh npm i @sebalon/scripting ``` + +Then import the functions you need into your scripts. + +```ts +import { /* ... */ } from '@sebalon/scripting'; +``` + +### Shell + +Run shell commands using `sh`. It returns a `Promise` for the command's output. + +```ts +const output = await sh('echo hello'); +// => 'hello' +``` + +By default, leading and trailing whitespace is trimmed from the output. You can disable this by passing `trim: false` in the options. + +```ts +const output = await sh('echo hello', { trim: false }); +// => 'hello\n' +``` + +If you also need to inspect `stderr`, use `exec` which is the promisified version of [Node's `child_process.exec`](https://nodejs.org/api/child_process.html#child_processexeccommand-options-callback). + +```ts +const output = await exec('echo hello'); +// => { stdout: 'hello\n', stderr: '' } +``` diff --git a/package-lock.json b/package-lock.json index 9a7e464..ee21021 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,11 +12,13 @@ "@comicrelief/eslint-config": "^2.0.3", "@istanbuljs/nyc-config-typescript": "^1.0.2", "@types/chai": "^4.3.4", + "@types/chai-as-promised": "^7.1.5", "@types/mocha": "^10.0.1", "@types/node": "^18.15.13", "@typescript-eslint/eslint-plugin": "^5.59.0", "@typescript-eslint/parser": "^5.59.0", "chai": "^4.3.7", + "chai-as-promised": "^7.1.1", "eslint": "^8.38.0", "eslint-plugin-import": "^2.27.5", "eslint-plugin-jsdoc": "^39.9.1", @@ -1343,6 +1345,15 @@ "integrity": "sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==", "dev": true }, + "node_modules/@types/chai-as-promised": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@types/chai-as-promised/-/chai-as-promised-7.1.5.tgz", + "integrity": "sha512-jStwss93SITGBwt/niYrkf2C+/1KTeZCZl1LaeezTlqppAKeoQC7jxyqYuP72sxBGKCIbw7oHgbYssIRzT5FCQ==", + "dev": true, + "dependencies": { + "@types/chai": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", @@ -2121,6 +2132,18 @@ "node": ">=4" } }, + "node_modules/chai-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", + "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", + "dev": true, + "dependencies": { + "check-error": "^1.0.2" + }, + "peerDependencies": { + "chai": ">= 2.1.2 < 5" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -12750,6 +12773,15 @@ "integrity": "sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==", "dev": true }, + "@types/chai-as-promised": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@types/chai-as-promised/-/chai-as-promised-7.1.5.tgz", + "integrity": "sha512-jStwss93SITGBwt/niYrkf2C+/1KTeZCZl1LaeezTlqppAKeoQC7jxyqYuP72sxBGKCIbw7oHgbYssIRzT5FCQ==", + "dev": true, + "requires": { + "@types/chai": "*" + } + }, "@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", @@ -13294,6 +13326,15 @@ "type-detect": "^4.0.5" } }, + "chai-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", + "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", + "dev": true, + "requires": { + "check-error": "^1.0.2" + } + }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", diff --git a/package.json b/package.json index 41d3a57..704f639 100644 --- a/package.json +++ b/package.json @@ -28,11 +28,13 @@ "@comicrelief/eslint-config": "^2.0.3", "@istanbuljs/nyc-config-typescript": "^1.0.2", "@types/chai": "^4.3.4", + "@types/chai-as-promised": "^7.1.5", "@types/mocha": "^10.0.1", "@types/node": "^18.15.13", "@typescript-eslint/eslint-plugin": "^5.59.0", "@typescript-eslint/parser": "^5.59.0", "chai": "^4.3.7", + "chai-as-promised": "^7.1.1", "eslint": "^8.38.0", "eslint-plugin-import": "^2.27.5", "eslint-plugin-jsdoc": "^39.9.1", diff --git a/src/index.ts b/src/index.ts index ece7aae..033bc73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1 @@ -/** - * Returns a greeting. - * - * @param name Optional name to greet. - */ -export function greet(name?: string): string { - return `Hello ${name || 'world'}!`; -} +export * from './shell'; diff --git a/src/shell.ts b/src/shell.ts new file mode 100644 index 0000000..ed51ffc --- /dev/null +++ b/src/shell.ts @@ -0,0 +1,55 @@ +import { ExecOptions, exec as execWithCallback } from 'child_process'; +import { promisify } from 'util'; + +/** + * Promisified version of `child_process.exec`. + * + * Executes `command` within a shell and return an object containing stdout and + * stderr, or throws an error if `command` completes with a non-zero exit code. + * + * See the official Node documentation here for more details: + * https://nodejs.org/api/child_process.html#child_processexeccommand-options-callback + * + * ```ts + * import { exec } from '@sebalon/scripting'; + * + * const output = await exec('echo hello'); + * // => { stdout: 'hello\n', stderr: '' } + * ``` + */ +export const exec = promisify(execWithCallback); + +/** + * Options for `sh`. + */ +export type ShOptions = ExecOptions & { + /** + * Trim leading and trailing whitespace from the output. + * + * Default: true + */ + trim?: boolean; +}; + +/** + * Executes `command` within a shell and returns its output (stdout), or throws + * an error if `command` completes with a non-zero exit code. + * + * ```ts + * import { sh } from '@sebalon/scripting'; + * + * const output = await sh('echo hello'); + * // => 'hello' + * ``` + * + * @param command Command to run. + * @param options Options to be passed to `exec`. + */ +export async function sh(command: string, options?: ShOptions): Promise { + const result = await exec(command, options); + let output = result.stdout.toString(); + if (options?.trim ?? true) { + output = output.trim(); + } + return output; +} diff --git a/tests/greet.spec.ts b/tests/greet.spec.ts deleted file mode 100644 index 6e6196b..0000000 --- a/tests/greet.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { expect } from 'chai'; - -import { greet } from '@/src'; - -describe('greet', () => { - context('with a name specified', () => { - it('should greet that name', () => { - expect(greet('TypeScript')).to.equal('Hello TypeScript!'); - }); - }); - - context('without a name specified', () => { - it('should greet the world', () => { - expect(greet()).to.equal('Hello world!'); - }); - }); -}); diff --git a/tests/setup.ts b/tests/setup.ts index 3031cd6..1d48d69 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1 +1,4 @@ -// add test setup here, e.g. load dotenv, register chai plugins, ... +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +chai.use(chaiAsPromised); diff --git a/tests/shell.spec.ts b/tests/shell.spec.ts new file mode 100644 index 0000000..0a9d1d1 --- /dev/null +++ b/tests/shell.spec.ts @@ -0,0 +1,38 @@ +import { expect } from 'chai'; + +import { exec, sh } from '@/src'; + +describe('exec', () => { + it('should run a command and return stdout and stderr', async () => { + const output = await exec('echo "hello stdout"; echo "hello stderr" >&2'); + expect(output.stdout).to.equal('hello stdout\n'); + expect(output.stderr).to.equal('hello stderr\n'); + }); + + it('should throw if the command fails', async () => { + await expect(exec('false')).to.eventually.be.rejected; + }); +}); + +describe('sh', () => { + it('should run a command and return its stdout, trimmed by default', async () => { + const output = await sh('echo hello'); + expect(output).to.equal('hello'); + }); + + it('should throw if the command fails', async () => { + await expect(sh('false')).to.eventually.be.rejected; + }); + + describe('options.trim', () => { + it('if true, should remove leading and trailing spaces', async () => { + const output = await sh('echo " hello "', { trim: true }); + expect(output).to.equal('hello'); + }); + + it('if false, should keep leading and trailing spaces', async () => { + const output = await sh('echo " hello "', { trim: false }); + expect(output).to.equal(' hello \n'); + }); + }); +});