0.5.4 • Published 1 year ago

@jordanrickman/b v0.5.4

Weekly downloads
-
License
MIT
Repository
github
Last release
1 year ago

b.js

b is a DSL for calling shell commands from Node, powered by the tagged template literal syntax. Instead of writing lots of calls to spawnSync(), you can just write

const runTestSuite = (env, noCoverage) => {
  b`npm install`
  b`docker-compose up -d test_services`
  b`./scripts/start_test_database --env=${env}`
  b`jest ${noCoverage ?? '--coverage=false'}`
  b`./scripts/stop_test_database`
  if (!noCoverage) {
    b`./upload_coverage.sh`
  }
}

b gives you all the expressive power of Bash one-liners, right in your JavaScript code!

Contents

Get it

npm i @jordanrickman/b

The module exports a single function.

const b = require('@jordanrickman/b')
// Or, using ESM syntax
import * as b from '@jordanrickman/b'

Async, but Sequential

b wouldn't be much fun if you had to write this:

await b`my first command`
await b`a second command`
await b`the third, all in order`

However, if we used child_process.spawnSync, there'd be lots of fancy things we couldn't do. Such as, we couldn't echo child process stdout/stderr, while also capturing it for you to save to a variable.

b chooses a hybrid approach. Each b tag returns a Promise, and if you want access to command results, you have to await that promise (or use then()).

const { status, stdout } = await b`ls -la`
if (status === 0) {
  console.log(stdout)
} else {
  console.log(`ls failed with exit code ${status}`)
}

However, b secretly chains all those Promises together, such that one command won't start until the previous one finishes. So for most purposes, you can still treat b calls as sequential.

Just remember to wait for them all to finish before you do anything that depends on their side effects.

b`unzip big_archive.zip big_archive`
const text = readFileSync('big_archive/data.txt') // Oops!
                                                  // The file isn't there yet.

You can just await your last b command; it will happen after the ones before it.

b`cp a.zip b.zip`
b`unzip b.zip c/`
await b`cat c/data.txt | grep ${searchText} > matched.txt`
const matches = readFileSync('matched.txt', 'utf8')

Or, you can use b.waitAll(), whose promise resolves once b's queue has finished.

Parallelization

If you need to, you can run things in parallel instead of sequentially with b.fork, which "forks" off a new queue of b commands.

// command 3 will wait for command 1, but not for command 2
b`command 1`
b.fork`command 2`
b`command 3`

// Save a forked `b` instance, whose shell commands will run
// in sequence with themselves, but parallel to the main instance
// (or to other forked instances).
const b2 = b.fork()
b2`command a`
b2`command b`
b2`command c`
await b2.waitAll() // wait for b2's queue to finish

// Or, you can pass fork() a sequence of commands to run in a parallel "thread"
const parallelTask = b.fork((b_) => {
  b_`command theFirst`
  b_`command theSecond`
  b_`command theThird`
})

// It returns a new `b` object, just like `fork()` does.
parallelTask`command oneMoreThingAtTheEnd`
await parallelTask.waitAll()

You can also use b.bg to start a command that will keep running after your JS code exits.

Exception Handling

Also, unlike spawnSync() and other child_process APIs, which just set the error attribute, b raises an exception when a process fails to spawn.

By default, b also raises an exception if a shell command produces a non-zero exit code. This can be relaxed with b.mayfail.

When trying to catch these, keep in mind they will come in as Promise rejections; a call to b will not itself throw any of these (unless awaited).

Return Signature

b's Promise resolves with the same object structure as that returned by child_process.spawnSync(), except that it converts the I/O from Buffers into Strings. The specific type signature is

{
  pid: number; // PID of the child process
  output: [string, string, string]; // [stdin, stdout, stderr]
  stdout: string; // Identical to output[1]
  stderr: string; // Identical to output[2]
  status?: number; // Exit code, or undefined if the process was terminated by a signal
  signal?: string; // If it was terminated by a signal, the signal used to terminate the process
  error?: Error; // The error object if the child process failed or timed out
}

As it is so common, there is a convenience method b.stdoutof that just gets the stdout of a command. Unlike most other b methods (see Options), this will have a different return signature, so you will lose access to all other return values. Use when you need a command's output, and nothing else.

const result = await b.stdoutof`echo Hello`
console.log(result) // Hello

Template Parsing

b applies some smart processing to your template literals. To see all the possibilities, look in test/b.test.js, at the test cases for the _interpolate function. Some highlights include:

  • Strings are wrapped in double quotes.
b`cat ${'My File Name.txt'}` // > cat "My File Name.txt"
  • Use b.raw to avoid quoting strings.
b`cp ${b.raw('../source ./destination')}` // > cp ../source ./destination
b`ls /path/to/${b.raw('directory')}` // > ls /path/to/directory
  • Use b.squote to single quote / (aka "strong quote") strings
b`echo ${b.squote('these variables are $not $read by Bash')}`
// > echo 'these variables are $not $read by Bash'
  • Arrays are converted to space-separated lists.
b`git add ${['file1', 'file 2', 'file3']}` // > git add "file1" "file 2" "file3"
  • Null and undefined are ignored.
b`echo Hello ${undefined}World` // > echo Hello World
b`git add ${['file1', null, 'file 2']}` // > git add "file1" "file 2"
  • Functions are executed before running the command, but after previous commands have finished. Async functions can be used.
b`doSomething > tempfile`
b`echo file contents: ${() => readFileSync('tempfile')}`

b`uploadDocument --id=${docId} myDocument.json`
b`echo uploaded contents: ${async () => docServer.get('/document/'+docId)}`

Stdio

Because b was written to help with scripting / automation, it prints stdout and stderr by default, piping them to the stdout/stderr of the parent process. It also pipes the parent process stdin to the child stdin, allowing you to run interactive commands.

You can override this behavior with b.quiet and b.silent

b.quiet`cat averylongfile.txt` // stdout is ignored
b.silent`echo "Don't show this error" >&2` // stdout AND stderr are ignored

For extra information, if you use b.echo (or b.with({ echo: true })), b will print each command string before it runs them.

b.echo`echo "Hello, World!"`
// echo "Hello, World!"
// Hello, World!

Be careful when echoing commands that might contain passwords or other sensitive data. Obfuscation of data in echo might be a feature in the future, but is not supported right now.

Options

You can configure b behavior with various options. All options can be set in a few different ways.

// Apply one option to a single command
b.optionName('value')`command ${template} as normal`
b.optionName`command` // for a boolean option

// Apply multiple options to a single command
b.with({ optionA: true, optionB: 'value', optionC: true })
// ... or chain them, fluent-style
b.optionA.optionB('value').optionC`command`

// Set options globally
b.config({ optionA: true, optionB: 'value', optionC: true })

// Because you can chain options, you can also save configured instances.
const silentB = b.with({ silent: true })
silentB`command 1`
silentB`command 2`
silentB`command 3`

// Note that unlike fork(), these will still run in the same sequence
// (or, in the sequence of a forked `b` instance they were made from).

// Finally, you can pass a function to with(), which will receive a
// configured `b` instance.
b.with({ optionA: true, optionB: 'value' }, b_ => {
  b_`command 1`
  b_`command 2`
  b_`command 3`
})

// b.with({ ... }).fork(<function>) can be a useful combination.

Set current working directory

b.cd('/path/to/directory')`command string`
b.with({ cd: '/path/to/directory' })`command string`

Set environment variables

b.env({ VARIABLE_NAME: 'value' })`command string`
b.with({ env: { VARIABLE_NAME: 'value' }})`command string`

Note that the environment of the parent process is automatically inherited - this is necessary to, for instance, retain the same PATH. If you want to ensure an environment variable is not set, try setting it to null (NOT undefined).

Don't raise an exception on failure

b.mayfail`command string`
b.with({ mayfail: true })`command string`

Default behavior is to raise an exception (Promise rejection) either on a non-zero exit code, or if the child process is terminated by a signal (e.g. Ctrl-C / SIGINT). Setting this flag to true disables both cases. Errors will still be raised if the child process fails to start.

Echo command input

b.echo`command string`
b.with({ echo: true })

Print each command string to stdout before executing.

Suppress command output

b.quiet`command string` // Don't echo stdout
b.silent`command string` // Don't echo stderr OR stdout
b.with({ quiet: true, silent: true }) // (redundant; silent implies quiet)

Default behavior is to pipe stdout and stderr from the child process to the parent process, meaning the parent will emit these as the child does (and on the same channels). These flags prevent that. You can still access the stdout and stderr attributes in the Promise result.

Run a process in the background

WARNING! This currently has some bugs, and will leave b in a bad state, so don't use it.

b.bg`long-running command`
b.with({ bg: true })`long-running command`

The process will be fully detached, and will keep running after your JS code quits. We capture no process information apart from PID, and the returned Promise will resolve immediately.

Set user or group of the child process

b.user('username')`command string`
b.group('groupname')`command string`
b.uid(n)`command string`
b.gid(n)`command string`

Timeout process after n milliseconds

b.timeout(n)`command string`
b.with({ timeout: n })`command string`

After the timeout, we attempt to kill the process using SIGTERM. This may not work (the process may catch it).

When not to Use b

I hope primarily to give you a new possibility for flexible, JavaScript-based task automation.

I am not a systems programmer, or an inter-process communication expert. You shouldn't use b to manage subprocesses in production code, and you probably shouldn't use it in things like production build chains, where you need a guarantee that certain commands were run with certain exact arguments.

Also, I have made only minimal effort to support non-Unix systems. You're welcome to try it on Windows, but... good luck.

Security

b is a shell command runner. Strings passed into b can do anything. Don't pass user input into b. Ever.

Why the name?

A single-letter tag to keep it short. b as in Bash, a good mnemonic for what it does with your string templates.

0.5.4

1 year ago

0.5.3

1 year ago

0.5.0

1 year ago

0.5.2

1 year ago

0.5.1

1 year ago

0.2.1

2 years ago

0.4.1

2 years ago

0.3.1

2 years ago

0.1.1

2 years ago

0.1.0

2 years ago