dax
Cross-platform shell tools for Deno and Node.js inspired by zx.
Differences with zx
- Cross-platform shell.
- Makes more code work on Windows.
- Allows exporting the shell's environment to the current process.
- Uses deno_task_shell's parser.
- Has common commands built-in for better Windows support.
- Minimal globals or global configuration.
- Only a default instance of
$
, but it's not mandatory to use this.
- No custom CLI.
- Good for application code in addition to use as a shell script replacement.
- Named after my cat.
Install
Deno:
deno add @david/dax
Node:
npm install dax-sh
Executing commands
#!/usr/bin/env -S deno run --allow-all
import $ from "@david/dax";
await $`echo 5`;
await $`echo 1 && deno run main.ts`;
await Promise.all([
$`sleep 1 ; echo 1`,
$`sleep 2 ; echo 2`,
$`sleep 3 ; echo 3`,
]);
Getting output
Get the stdout of a command (makes stdout "quiet"):
const result = await $`echo 1`.text();
console.log(result);
Get the result of stdout as json (makes stdout "quiet"):
const result = await $`echo '{ "prop": 5 }'`.json();
console.log(result.prop);
Get the result of stdout as bytes (makes stdout "quiet"):
const bytes = await $`gzip < file.txt`.bytes();
console.log(bytes);
Get the result of stdout as a list of lines (makes stdout "quiet"):
const result = await $`echo 1 && echo 2`.lines();
console.log(result);
Get stderr's text:
const result = await $`deno eval "console.error(1)"`.text("stderr");
console.log(result);
Working with a lower level result that provides more details:
const result = await $`deno eval 'console.log(1); console.error(2);'`
.stdout("piped")
.stderr("piped");
console.log(result.code);
console.log(result.stdoutBytes);
console.log(result.stdout);
console.log(result.stderr);
const output = await $`echo '{ "test": 5 }'`.stdout("piped");
console.log(output.stdoutJson);
Getting the combined output:
const text = await $`deno eval 'console.log(1); console.error(2); console.log(3);'`
.text("combined");
console.log(text);
Piping
Piping stdout or stderr to a Deno.WriterSync
:
await $`echo 1`.stdout(Deno.stderr);
await $`deno eval 'console.error(2);`.stderr(Deno.stdout);
Piping to a WritableStream
:
await $`echo 1`.stdout(Deno.stderr.writable, { preventClose: true });
await $`echo 1 > ${someWritableStream}`;
To a file path:
await $`echo 1`.stdout($.path("data.txt"));
await $`echo 1 > data.txt`;
await $`echo 1 > ${$.path("data.txt")}`;
To a file:
using file = $.path("data.txt").openSync({ write: true, create: true });
await $`echo 1`.stdout(file);
await $`echo 1 > ${file}`;
From one command to another:
const output = await $`echo foo && echo bar`
.pipe($`grep foo`)
.text();
const output = await $`(echo foo && echo bar) | grep foo`
.text();
Providing arguments to a command
Use an expression in a template literal to provide a single argument to a command:
const dirName = "some_dir";
await $`mkdir ${dirName}`;
Arguments are escaped so strings with spaces get escaped and remain as a single argument:
const dirName = "Dir with spaces";
await $`mkdir ${dirName}`;
Alternatively, provide an array for multiple arguments:
const dirNames = ["some_dir", "other dir"];
await $`mkdir ${dirNames}`;
If you do not want to escape arguments in a template literal, you can opt out completely, by using $.raw
:
const args = "arg1 arg2 arg3";
await $.raw`echo ${args}`;
const args2 = "arg1 arg2";
await $.raw`echo ${$.escape(args2)} ${args2}`;
Providing stdout of one command to another is possible as follows:
const result = await $`echo 1`.stdout("piped");
const finalText = await $`echo ${result}`.text();
console.log(finalText);
...though it's probably more straightforward to just collect the output text of a command and provide that:
const result = await $`echo 1`.text();
const finalText = await $`echo ${result}`.text();
console.log(finalText);
JavaScript objects to redirects
You can provide JavaScript objects to shell output redirects:
const buffer = new Uint8Array(2);
await $`echo 1 && (echo 2 > ${buffer}) && echo 3`;
console.log(buffer);
Supported objects: Uint8Array
, Path
, WritableStream
, any function that returns a WritableStream
, any object that implements [$.symbols.writable](): WritableStream
Or input redirects:
const data = "my data in a string";
const bytes = await $`gzip < ${data}`;
const path = $.path("file.txt");
const bytes = await $`gzip < ${path}`;
const request = $.request("https://plugins.dprint.dev/info.json")
.showProgress();
const bytes = await $`sleep 5 && gzip < ${request}`.bytes();
Supported objects: string
, Uint8Array
, Path
, RequestBuilder
, ReadableStream
, any function that returns a ReadableStream
, any object that implements [$.symbols.readable](): ReadableStream
Providing stdin
await $`command`.stdin("inherit");
await $`command`.stdin("null");
await $`command`.stdin(new Uint8Array[1, 2, 3, 4]());
await $`command`.stdin(someReaderOrReadableStream);
await $`command`.stdin($.path("data.json"));
await $`command`.stdin($.request("https://plugins.dprint.dev/info.json"));
await $`command`.stdinText("some value");
Or using a redirect:
await $`command < ${$.path("data.json")}`;
Streaming API
Awaiting a command will get the CommandResult
, but calling .spawn()
on a command without await
will return a CommandChild
. This has some methods on it to get web streams of stdout and stderr of the executing command if the corresponding pipe is set to "piped"
. These can then be sent wherever you'd like, such as to the body of a $.request
or another command's stdin.
For example, the following will output 1, wait 2 seconds, then output 2 to the current process' stderr:
const child = $`echo 1 && sleep 1 && echo 2`.stdout("piped").spawn();
await $`deno eval 'await Deno.stdin.readable.pipeTo(Deno.stderr.writable);'`
.stdin(child.stdout());
Setting environment variables
Done via the .env(...)
method:
await $`echo $var1 $var2 $var3 $var4`
.env("var1", "1")
.env("var2", "2")
.env({
var3: "3",
var4: "4",
});
Setting cwd for command
Use .cwd("new_cwd_goes_here")
:
await $`deno eval 'console.log(Deno.cwd());'`.cwd("./someDir");
Silencing a command
Makes a command not output anything to stdout and stderr.
await $`echo 5`.quiet();
await $`echo 5`.quiet("stdout");
await $`echo 5`.quiet("stderr");
Output a command before executing it
The following code:
const text = "example";
await $`echo ${text}`.printCommand();
Outputs the following (with the command text in blue):
> echo example
example
Enabling on a $
Like with any default in Dax, you can build a new $
turning on this option so this will occur with all commands (see Custom $
). Alternatively, you can enable this globally by calling $.setPrintCommand(true);
.
$.setPrintCommand(true);
const text = "example";
await $`echo ${text}`;
Timeout a command
This will exit with code 124 after 1 second.
await $`echo 1 && sleep 100 && echo 2`.timeout("1s");
Aborting a command
Instead of awaiting the template literal, you can get a command child by calling the .spawn()
method:
const child = $`echo 1 && sleep 100 && echo 2`.spawn();
await doSomeOtherWork();
child.kill();
await child;
KillSignalController
In some cases you might want to send signals to many commands at the same time. This is possible via a KillSignalController
.
import $, { KillSignalController } from "...";
const controller = new KillSignalController();
const signal = controller.signal;
const promise = Promise.all([
$`sleep 1000s`.signal(signal),
$`sleep 2000s`.signal(signal),
$`sleep 3000s`.signal(signal),
]);
$.sleep("1s").then(() => controller.kill());
await promise;
Combining this with the CommandBuilder
API and building your own $
as shown later in the documentation, can be extremely useful for sending a Deno.Signal
to all commands you've spawned.
Exporting the environment of the shell to JavaScript
When executing commands in the shell, the environment will be contained to the shell and not exported to the current process. For example:
await $`cd src && export MY_VALUE=5`;
await $`echo $MY_VALUE`;
await $`echo $PWD`;
console.log(Deno.cwd());
You can change that by using exportEnv()
on the command:
await $`cd src && export MY_VALUE=5`.exportEnv();
await $`echo $MY_VALUE`;
await $`echo $PWD`;
console.log(Deno.cwd());
Logging
Dax comes with some helper functions for logging:
$.log("Hello!");
$.logStep("Fetching data from server...");
$.logStep("Setting up", "local directory...");
$.logError("Error Some error message.");
$.logWarn("Warning Some warning message.");
$.logLight("Some unimportant message.");
You may wish to indent some text when logging, use $.logGroup
to do so:
await $.logGroup(async () => {
$.log("This will be indented.");
await $.logGroup(async () => {
$.log("This will indented even more.");
});
});
$.logGroup();
$.log("Indented 1");
$.logGroup("Level 2");
$.log("Indented 2");
$.logGroupEnd();
$.logGroupEnd();
As mentioned previously, Dax logs to stderr for everything by default. This may not be desired, so you can change the current behaviour of a $
object by setting a logger for either "info", "warn", or "error".
$.setInfoLogger(console.log);
$.setWarnLogger(console.log);
$.setErrorLogger(console.log);
$.setInfoLogger((...args: any[]) => {
console.error(...args);
};)
Selections / Prompts
There are a few selections/prompts that can be used.
By default, all prompts will exit the process if the user cancelled their selection via ctrl+c. If you don't want this behaviour, then use the maybe
variant functions.
$.prompt
/ $.maybePrompt
Gets a string value from the user:
const name = await $.prompt("What's your name?");
const name = await $.prompt({
message: "What's your name?",
default: "Dax",
noClear: true,
});
const name = await $.prompt("What's your name?", {
default: "Dax",
});
const password = await $.prompt("What's your password?", {
mask: true,
});
Again, you can use $.maybePrompt("What's your name?")
to get a nullable return value for when the user presses ctrl+c
.
$.confirm
/ $.maybeConfirm
Gets the answer to a yes or no question:
const result = await $.confirm("Would you like to continue?");
const result = await $.confirm({
message: "Would you like to continue?",
default: true,
});
const result = await $.confirm("Would you like to continue?", {
default: false,
noClear: true,
});
$.select
/ $.maybeSelect
Gets a single value:
const index = await $.select({
message: "What's your favourite colour?",
options: [
"Red",
"Green",
"Blue",
],
});
$.multiSelect
/ $.maybeMultiSelect
Gets multiple or no values:
const indexes = await $.multiSelect({
message: "Which of the following are days of the week?",
options: [
"Monday",
{
text: "Wednesday",
selected: true,
},
"Blue",
],
});
Progress indicator
You may wish to indicate that some progress is occurring.
Indeterminate
const pb = $.progress("Updating Database");
await pb.with(async () => {
});
The .with(async () => { ... })
API will hide the progress bar when the action completes including hiding it when an error is thrown. If you don't want to bother with this though you can just call pb.finish()
instead.
const pb = $.progress("Updating Database");
try {
} finally {
pb.finish();
}
Determinate
Set a length to be determinate, which will display a progress bar:
const items = [];
const pb = $.progress("Processing Items", {
length: items.length,
});
await pb.with(async () => {
for (const item of items) {
await doWork(item);
pb.increment();
}
});
Synchronous work
The progress bars are updated on an interval (via setInterval
) to prevent rendering more than necessary. If you are doing a lot of synchronous work the progress bars won't update. Due to this, you can force a render where you think it would be appropriate by using the .forceRender()
method:
const pb = $.progress("Processing Items", {
length: items.length,
});
pb.with(() => {
for (const item of items) {
doWork(item);
pb.increment();
pb.forceRender();
}
});
Path API
The path API offers an immutable Path
class via jsr:@david/path
, which is a similar concept to Rust's PathBuf
struct.
let srcDir = $.path("src");
srcDir.isDirSync();
await srcDir.mkdir();
srcDir.isDirSync();
srcDir.isRelative();
srcDir = srcDir.resolve();
srcDir.isRelative();
srcDir.isAbsolute();
const textFile = srcDir.join("subDir").join("file.txt");
textFile.writeTextSync("some text");
console.log(textFile.readTextSync());
const jsonFile = srcDir.join("otherDir", "file.json");
console.log(jsonFile.parentOrThrow());
jsonFile.writeJsonSync({
someValue: 5,
});
console.log(jsonFile.readJsonSync().someValue);
It also works to provide these paths to commands:
const srcDir = $.path("src").resolve();
await $`echo ${srcDir}`;
Path
s can be created in the following ways:
const pathRelative = $.path("./relative");
const pathAbsolute = $.path("/tmp");
const pathFileUrl = $.path(new URL("file:///tmp"));
const pathStringFileUrl = $.path("file:///tmp");
const pathImportMeta = $.path(import.meta);
There are a lot of helper methods here, so check the documentation on Path for more details.
Helper functions
Changing the current working directory of the current process:
$.cd("someDir");
console.log(Deno.cwd());
$.cd(import.meta);
Sleeping asynchronously for a specified amount of time:
await $.sleep(100);
await $.sleep("1.5s");
await $.sleep("1m30s");
Getting path to an executable based on a command name:
console.log(await $.which("deno"));
Check if a command exists:
console.log(await $.commandExists("deno"));
console.log($.commandExistsSync("deno"));
Attempting to do an action until it succeeds or hits the maximum number of retries:
await $.withRetries({
count: 5,
delay: "5s",
action: async () => {
await $`cargo publish`;
},
});
"Dedent" or remove leading whitespace from a string:
console.log($.dedent`
This line will appear without any indentation.
* This list will appear with 2 spaces more than previous line.
* As will this line.
Empty lines (like the one above) will not affect the common indentation.
`);
This line will appear without any indentation.
* This list will appear with 2 spaces more than previous line.
* As will this line.
Empty lines (like the one above) will not affect the common indentation.
Remove ansi escape sequences from a string:
$.stripAnsi("\u001B[4mHello World\u001B[0m");
Making requests
Dax ships with a slightly less verbose wrapper around fetch
that will throw by default on non-2xx
status codes (this is configurable per status code).
Download a file as JSON:
const data = await $.request("https://plugins.dprint.dev/info.json").json();
console.log(data.plugins);
Or as text:
const text = await $.request("https://example.com").text();
Or get the long form:
const response = await $.request("https://plugins.dprint.dev/info.json");
console.log(response.code);
console.log(await response.json());
Requests can be piped to commands:
const request = $.request("https://plugins.dprint.dev/info.json");
await $`deno run main.ts`.stdin(request);
await $`sleep 5 && deno run main.ts < ${request}`;
See the documentation on RequestBuilder
for more details. It should be as flexible as fetch
, but uses a builder API (ex. set headers via .header(...)
).
Showing progress
You can have downloads show a progress bar by using the .showProgress()
builder method:
const url = "https://dl.deno.land/release/v1.29.1/deno-x86_64-unknown-linux-gnu.zip";
const downloadPath = await $.request(url)
.showProgress()
.pipeToPath();
Shell
The shell is cross-platform and uses the parser from deno_task_shell.
Sequential lists:
const result = await $`cd someDir ; deno eval 'console.log(Deno.cwd())'`;
Boolean lists:
await $`echo 1 && echo 2`;
await $`echo 1 || echo 2`;
Pipe sequences:
await $`echo 1 | deno run main.ts`;
Redirects:
await $`echo 1 > output.txt`;
const gzippedBytes = await $`gzip < input.txt`.bytes();
Sub shells:
await $`(echo 1 && echo 2) > output.txt`;
Setting env var for command in the shell (generally you can just use .env(...)
though):
const result = await $`test=123 deno eval 'console.log(Deno.env.get('test'))'`;
console.log(result.stdout);
Shell variables (these aren't exported):
await $`test=123 && deno eval 'console.log(Deno.env.get('test'))' && echo $test`;
Env variables (these are exported):
await $`export test=123 && deno eval 'console.log(Deno.env.get('test'))' && echo $test`;
Variable substitution:
const result = await $`echo $TEST`.env("TEST", "123").text();
console.log(result);
Custom cross-platform shell commands
Currently implemented (though not every option is supported):
cd
- Change directory command.
- Note that shells don't export their environment by default.
echo
- Echo command.exit
- Exit command.cp
- Copies files.mv
- Moves files.rm
- Remove files or directories command.mkdir
- Makes
directories.
- Ex.
mkdir -p DIRECTORY...
- Commonly used to make a directory and all its
parents with no error if it exists.
pwd
- Prints the current/working directory.sleep
- Sleep command.test
- Test command.touch
- Creates a file (note: flags have not been implemented yet).unset
- Unsets an environment variable.cat
- Concatenate files and print on the standard outputprintenv
- Print all or part of environmentwhich
- Resolves the path to an executable (-a
flag is not supported at this time)- More to come. Will try to get a similar list as https://deno.land/manual/tools/task_runner#built-in-commands
You can also register your own commands with the shell parser (see below).
Note that these cross-platform commands can be bypassed by running them through sh
: sh -c <command>
(ex. sh -c cp source destination
). Obviously doing this won't work on Windows though.
Cross-platform shebang support
Users on unix-based platforms often write a script like so:
#!/usr/bin/env -S deno run
console.log("Hello there!");
...which can be executed on the command line by running ./file.ts
. This doesn't work on the command line in Windows, but it does on all platforms in dax:
await $`./file.ts`;
Builder APIs
The builder APIs are what the library uses internally and they're useful for scenarios where you want to re-use some setup state. They're immutable so every function call returns a new object (which is the same thing that happens with the objects returned from $
and $.request
).
CommandBuilder
CommandBuilder
can be used for building up commands similar to what the tagged template $
does:
import { CommandBuilder } from "@david/dax";
const commandBuilder = new CommandBuilder()
.cwd("./subDir")
.stdout("inheritPiped")
.noThrow();
const otherBuilder = commandBuilder
.stderr("null");
const result = await commandBuilder
.command("deno run my_script.ts")
.spawn();
const result2 = await otherBuilder
.command("deno run my_script.ts")
.spawn();
You can also register your own custom commands using the registerCommand
or registerCommands
methods:
const commandBuilder = new CommandBuilder()
.registerCommand(
"true",
() => Promise.resolve({ code: 0 }),
);
const result = await commandBuilder
.command("true && echo yay")
.spawn();
RequestBuilder
RequestBuilder
can be used for building up requests similar to $.request
:
import { RequestBuilder } from "@david/dax";
const requestBuilder = new RequestBuilder()
.header("SOME_VALUE", "some value to send in a header");
const result = await requestBuilder
.url("https://example.com")
.timeout("10s")
.text();
Custom $
You may wish to create your own $
function that has a certain setup context (for example, custom commands or functions on $
, a defined environment variable or cwd). You may do this by using the exported build$
with CommandBuilder
and/or RequestBuilder
, which is essentially what the main default exported $
uses internally to build itself. In addition, you may also add your own functions to $
:
import { build$, CommandBuilder, RequestBuilder } from "@david/dax";
const $ = build$({
commandBuilder: new CommandBuilder()
.cwd("./subDir")
.env("HTTPS_PROXY", "some_value"),
requestBuilder: new RequestBuilder()
.header("SOME_NAME", "some value"),
extras: {
add(a: number, b: number) {
return a + b;
},
},
});
await $`deno run my_script.ts`;
console.log(await $.request("https://plugins.dprint.dev/info.json").json());
console.log($.add(1, 2));
This may be useful also if you want to change the default configuration. Another example:
const commandBuilder = new CommandBuilder()
.exportEnv()
.noThrow();
const $ = build$({ commandBuilder });
await $`cd test && export MY_VALUE=5`;
await $`echo $MY_VALUE`;
await $`echo $PWD`;
await $`deno eval 'Deno.exit(1);'`;
Building $
from another $
You can build a $
from another $
by calling $.build$({ /* options go here */ })
.
This might be useful in scenarios where you want to use a $
with a custom logger.
const local$ = $.build$();
local$.setInfoLogger((...args: any[]) => {
console.log("Logging...");
console.log(...args);
});
local$.log("Hello!");
Outputs:
Logging...
Hello!