dax - Cross-platform shell tools for Deno
Automation scripts in repositories are often written in a shell scripting language such as bash. That's not ideal—on top of these scripting languages being difficult to use, they're not cross-platform. This makes it harder for Windows users to contribute and there are often small differences between Linux and Mac (or shell configurations) that may lead to broken scripts for contributors.
Using a cross-platform programming language, like JavaScript, is more ideal, but often what can be expressed in a shell scripting language succinctly (such as executing a command) is verbose when using the APIs offered out of the box by JavaScript runtimes.
The library zx made this a lot easier by bringing the best of shell scripting languages into JavaScript with the introduction of an easy to use API, but I believe there are some improvements that can be made to this idea.
In this post I'm going to outline a new tool called dax, which is inspired by zx.
// example dax API usage
let branch = await $`git branch --show-current`.text();
await $`dep deploy --branch=${branch}`;
Cross-platform shell
dax has an API very similar to zx, but its shell is cross-platform using the parser from deno_task_shell with a rewrite of the execution logic in JavaScript.
So commands like the following work the same on Linux, Mac, and Windows:
await $`echo Hello`; // Hello
await $`MY_VAR=there && echo Hello $MY_VAR`; // Hello there
await $`LOG_LEVEL=0 some_command`;
Additionally, the shell has a few built-in cross-platform commands.
// outputs "Hello", sleeps for 1 second, then outputs "There"
await $`echo Hello && sleep 1 && echo There`;
Note: It's not possible to use a custom shell with dax as that's heavily
discouraged since it more easily leads to code that's not cross-platform. That
said, if you really need to call into sh
, for example, then you can run it
directly (sh -c <command>
).
Exporting shell environment
Any changes to the shell environment will not be exported to the executing process by default.
// outputs: C:\dev\my_project\sub_dir
await $`cd sub_dir && echo $PWD && export MY_VAR=5`;
// outputs: undefined
console.log(Deno.env.get("MY_VAR"));
// outputs: C:\dev\my_project
console.log(Deno.cwd());
However, the shell environment may be exported when desired by using the
.exportEnv()
method:
await $`cd sub_dir`.exportEnv();
await $`echo $PWD`; // C:\dev\my_project\sub_dir
console.log(Deno.cwd()); // C:\dev\my_project\sub_dir
await $`export MY_VAR=5 && cd ../`.exportEnv();
console.log(Deno.env.get("MY_VAR")); // 5
console.log(Deno.cwd()); // C:\dev\my_project
Note also, that you can modify a shell's environment before executing without changing the current process' environment:
// outputs:
// C:\dev\my_project\sub_dir
// 5
await $`echo $PWD && echo $MY_VAR`
.cwd("./sub_dir")
.env("MY_VAR", "5");
High level helpers with an available low level API
When executing commands and you want to get the output, there are several helper methods that make this easy:
// get the stdout of a command (makes stdout "quiet")
const result = await $`echo 1`.text();
console.log(result); // 1
// get the result of stdout as json (makes stdout "quiet")
const result = await $`echo '{ "prop": 5 }'`.json();
console.log(result.prop); // 5
// get the result of stdout as bytes (makes stdout "quiet")
const result = await $`echo 'test'`.bytes();
console.log(result); // Uint8Array(5) [ 116, 101, 115, 116, 10 ]
// get the result of stdout as a list of lines (makes stdout "quiet")
const result = await $`echo 1 && echo 2`.lines();
console.log(result); // ["1", "2"]
In the case you need to access to more detail, that is available too along with several other properties not shown here:
const result = await $`deno eval 'console.log(1); console.error(2);'`;
console.log(result.code); // 0
console.log(result.stdoutBytes); // Uint8Array(2) [ 49, 10 ]
console.log(result.stdout); // 1\n
console.log(result.stderr); // 5\n
const output = await $`echo '{ "test": 5 }'`;
console.log(output.stdoutJson);
No custom CLI or globals
Deno allows for expressing dependencies in code and this is perfect for automation scripts. Instead of needing to install a custom CLI or npm package, you can just import the module directly...
// script.js
import $ from "https://deno.land/x/dax@0.7.0/mod.ts";
await $`echo 'Hello there!'`;
...and run it right away...
> deno run -A script.js
Hello there!
This command could then be easily aliased in a deno task
with Deno's
task runner meaning your
contributors only need to execute the task to run the script without needing to
follow any other setup instructions.
Being able to express all your dependencies directly in your script files is a
huge advantage that Deno offers over Node.js. It makes it easy to import a
module you want to use in a single script without having to manage dev
dependencies and do an npm install
.
No global configuration
Using zx in a library or application code is a little risky because it has global configuration. With dax there is no global configuration in order to prevent modifying the behaviour of other code using it.
Additionally, with dax you can create your own local $
object to use that has
the configuration you like. This is done by using the builder APIs and build$
function.
import { build$, CommandBuilder } from "https://deno.land/x/dax@0.7.0/mod.ts";
const commandBuilder = new CommandBuilder()
.exportEnv()
.noThrow();
const $ = build$({ commandBuilder });
// since exportEnv() was set, this will now actually change
// the directory of the executing process
await $`cd test && export MY_VALUE=5`;
// will output "5"
await $`echo $MY_VALUE`;
// will output it's in the test dir
await $`echo $PWD`;
// won't throw even though this command fails (because of `.noThrow()`)
await $`deno eval 'Deno.exit(1);'`;
This CommandBuilder
API is what $
uses internally, so you can also design
your own APIs that execute shell commands using it.
Read more about the builder APIs here.
Utilities on $
Since this is a shell scripting toolkit, the module offers several utilities
built-in and has all of these available on the $
object for quick access.
Here's a few of them:
await $.sleep(100); // ms
await $.sleep("1.5s");
const denoPath = await $.which("deno"); // path to deno executable
// re-export of deno_std's path
const fileName = $.path.basename("./my_sub_dir/mod.ts"); // mod.ts
// re-export of deno_std's fs
for await (const file of $.fs.expandGlob("**/*.ts")) {
console.log(file);
}
Fetch alternative
There is a fetch
API alternative built-in, but with a less error-prone builder
API that throws on non-2xx status codes by default.
// download a file as JSON
const data = await $.request("https://plugins.dprint.dev/info.json").json();
// or long form
const response = await $.request("https://plugins.dprint.dev/info.json");
console.log(response.code);
console.log(await response.json());
Often enough myself and others would forget to handle non-success codes in
scripts with fetch
, leading to cryptic errors. So the $.request(..)
function
throws by default. That said, this can be disabled, or only disabled for
specific status codes, via the .noThrow()
method (ex.
await $.request("...").noThrow()
).
Note: Similarly to commands, if you don't like the defaults for $.request
, you
can use build$
to create your own $
with a RequestBuilder
that changes the
defaults (see custom $
).
Logging API
In an effort to simplify logging in scripts (reducing need for a color
API),
there is also a built-in logging API that only logs over stderr:
// logs with no formatting
$.log("Hello!");
// log with the first word as bold green
$.logStep("Fetching data from server...");
// or force multiple words to be green by using two arguments
$.logStep("Setting up", "local directory...");
// similar to $.logStep, but with red
$.logError("Error Some error message.");
// similar to $.logStep, but with yellow
$.logWarn("Warning Some warning message.");
// logs out text in gray
$.logLight("Some unimportant message.");
// log indented within
await $.logIndent(async () => {
$.log("This will be indented.");
await $.logIndent(async () => {
$.log("This will indented even more.");
// do maybe async stuff here
});
});
Looks like this:
Conclusion
I just started on this a little over a week ago so it might evolve a bit and have some breaking changes. Overall, I'd appreciate any feedback. I've already begun integrating this into some of our automation scripts used at Deno to try to find some of the pain points and verbose code that could be simplified.
As always, thanks for reading and I hope this is useful!
P.S. this module was originally called ax
, but deno.land/x doesn't allow 2
character module names... so I added a d
to the front for Deno, but that so
happens to be my cat's name and so this module is now named after him.