- Notifications
You must be signed in to change notification settings - Fork36
Cross-platform shell tools for Deno and Node.js inspired by zx.
License
dsherret/dax
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
Cross-platform shell tools for Deno and Node.js inspired byzx.
- Cross-platform shell.
- Makes more code work on Windows.
- Allows exporting the shell's environment to the current process.
- Usesdeno_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.
- Only a default instance of
- No custom CLI.
- Good for application code in addition to use as a shell script replacement.
- Named after my cat.
Deno:
# or skip and import directly from `jsr:@david/dax@<version>`deno add jsr:@david/dax
Node:
npm install dax-sh
#!/usr/bin/env -S deno run --allow-allimport$from"@david/dax";// "dax-sh" in Node// run a commandawait$`echo 5`;// outputs: 5// outputting to stdout and running a sub processawait$`echo 1 && deno run main.ts`;// parallelawaitPromise.all([$`sleep 1 ; echo 1`,$`sleep 2 ; echo 2`,$`sleep 3 ; echo 3`,]);
Get the stdout of a command (makes stdout "quiet"):
constresult=await$`echo 1`.text();console.log(result);// 1
Get the result of stdout as json (makes stdout "quiet"):
constresult=await$`echo '{ "prop": 5 }'`.json();console.log(result.prop);// 5
Get the result of stdout as bytes (makes stdout "quiet"):
constbytes=await$`gzip < file.txt`.bytes();console.log(bytes);
Get the result of stdout as a list of lines (makes stdout "quiet"):
constresult=await$`echo 1 && echo 2`.lines();console.log(result);// ["1", "2"]
Get stderr's text:
constresult=await$`deno eval "console.error(1)"`.text("stderr");console.log(result);// 1
Working with a lower level result that provides more details:
constresult=await$`deno eval 'console.log(1); console.error(2);'`.stdout("piped").stderr("piped");console.log(result.code);// 0console.log(result.stdoutBytes);// Uint8Array(2) [ 49, 10 ]console.log(result.stdout);// 1\nconsole.log(result.stderr);// 2\nconstoutput=await$`echo '{ "test": 5 }'`.stdout("piped");console.log(output.stdoutJson);
Getting the combined output:
consttext=await$`deno eval 'console.log(1); console.error(2); console.log(3);'`.text("combined");console.log(text);// 1\n2\n3\n
Piping stdout or stderr to aDeno.WriterSync
:
await$`echo 1`.stdout(Deno.stderr);await$`deno eval 'console.error(2);`.stderr(Deno.stdout);
Piping to aWritableStream
:
await$`echo 1`.stdout(Deno.stderr.writable,{preventClose:true});// or with a redirectawait$`echo 1 >${someWritableStream}`;
To a file path:
await$`echo 1`.stdout($.path("data.txt"));// orawait$`echo 1 > data.txt`;// orawait$`echo 1 >${$.path("data.txt")}`;
To a file:
usingfile=$.path("data.txt").openSync({write:true,create:true});await$`echo 1`.stdout(file);// orawait$`echo 1 >${file}`;
From one command to another:
constoutput=await$`echo foo && echo bar`.pipe($`grep foo`).text();// or using a pipe sequenceconstoutput=await$`(echo foo && echo bar) | grep foo`.text();
Use an expression in a template literal to provide a single argument to a command:
constdirName="some_dir";await$`mkdir${dirName}`;// executes as: mkdir some_dir
Arguments are escaped so strings with spaces get escaped and remain as a single argument:
constdirName="Dir with spaces";await$`mkdir${dirName}`;// executes as: mkdir 'Dir with spaces'
Alternatively, provide an array for multiple arguments:
constdirNames=["some_dir","other dir"];await$`mkdir${dirNames}`;// executes as: mkdir some_dir 'other dir'
If you do not want to escape arguments in a template literal, you can opt out completely, by using$.raw
:
constargs="arg1 arg2 arg3";await$.raw`echo${args}`;// executes as: echo arg1 arg2 arg3// or escape a specific argument while using $.rawconstargs2="arg1 arg2";await$.raw`echo${$.escape(args2)}${args2}`;// executes as: echo "arg1 arg2" arg1 arg2
Providing stdout of one command to another is possible as follows:
// Note: This will read trim the last newline of the other command's stdoutconstresult=await$`echo 1`.stdout("piped");// need to set stdout as piped for this to workconstfinalText=await$`echo${result}`.text();console.log(finalText);// 1
...though it's probably more straightforward to just collect the output text of a command and provide that:
constresult=await$`echo 1`.text();constfinalText=await$`echo${result}`.text();console.log(finalText);// 1
You can provide JavaScript objects to shell output redirects:
constbuffer=newUint8Array(2);await$`echo 1 && (echo 2 >${buffer}) && echo 3`;// 1\n3\nconsole.log(buffer);// Uint8Array(2) [ 50, 10 ] (2\n)
Supported objects:Uint8Array
,Path
,WritableStream
, any function that returns aWritableStream
, any object that implements[$.symbols.writable](): WritableStream
Or input redirects:
// stringsconstdata="my data in a string";constbytes=await$`gzip <${data}`;// pathsconstpath=$.path("file.txt");constbytes=await$`gzip <${path}`;// requests (this example does not make the request until after 5 seconds)constrequest=$.request("https://plugins.dprint.dev/info.json").showProgress();// show a progress bar while downloadingconstbytes=await$`sleep 5 && gzip <${request}`.bytes();
Supported objects:string
,Uint8Array
,Path
,RequestBuilder
,ReadableStream
, any function that returns aReadableStream
, any object that implements[$.symbols.readable](): ReadableStream
await$`command`.stdin("inherit");// defaultawait$`command`.stdin("null");await$`command`.stdin(newUint8Array[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")}`;
Awaiting a command will get theCommandResult
, but calling.spawn()
on a command withoutawait
will return aCommandChild
. 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:
constchild=$`echo 1 && sleep 1 && echo 2`.stdout("piped").spawn();await$`deno eval 'await Deno.stdin.readable.pipeTo(Deno.stderr.writable);'`.stdin(child.stdout());
Done via the.env(...)
method:
// outputs: 1 2 3 4await$`echo $var1 $var2 $var3 $var4`.env("var1","1").env("var2","2")// or use object syntax.env({var3:"3",var4:"4",});
Use.cwd("new_cwd_goes_here")
:
// outputs that it's in the someDir directoryawait$`deno eval 'console.log(Deno.cwd());'`.cwd("./someDir");
Makes a command not output anything to stdout and stderr.
await$`echo 5`.quiet();await$`echo 5`.quiet("stdout");// or just stdoutawait$`echo 5`.quiet("stderr");// or just stderr
The following code:
consttext="example";await$`echo${text}`.printCommand();
Outputs the following (with the command text in blue):
>echoexampleexample
Like with any default in Dax, you can build a new$
turning on this option so this will occur with all commands (seeCustom$
). Alternatively, you can enable this globally by calling$.setPrintCommand(true);
.
$.setPrintCommand(true);consttext="example";await$`echo${text}`;// will output `> echo example` before running the command
This will exit with code 124 after 1 second.
// timeout a command after a specified timeawait$`echo 1 && sleep 100 && echo 2`.timeout("1s");
Instead of awaiting the template literal, you can get a command child by calling the.spawn()
method:
constchild=$`echo 1 && sleep 100 && echo 2`.spawn();awaitdoSomeOtherWork();child.kill();// defaults to "SIGTERM"awaitchild;// Error: Aborted with exit code: 124
In some cases you might want to send signals to many commands at the same time. This is possible via aKillSignalController
.
import$,{KillSignalController}from"...";constcontroller=newKillSignalController();constsignal=controller.signal;constpromise=Promise.all([$`sleep 1000s`.signal(signal),$`sleep 2000s`.signal(signal),$`sleep 3000s`.signal(signal),]);$.sleep("1s").then(()=>controller.kill());// defaults to "SIGTERM"awaitpromise;// throws after 1 second
Combining this with theCommandBuilder
API and building your own$
as shown later in the documentation, can be extremely useful for sending aDeno.Signal
to all commands you've spawned.
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`;// will output nothingawait$`echo $MY_VALUE`;// will both NOT output it's in the src dirawait$`echo $PWD`;console.log(Deno.cwd());
You can change that by usingexportEnv()
on the command:
await$`cd src && export MY_VALUE=5`.exportEnv();// will output "5"await$`echo $MY_VALUE`;// will both output it's in the src dirawait$`echo $PWD`;console.log(Deno.cwd());
Dax comes with some helper functions for logging:
// logs with potential indentation// Note: everything is logged over stderr by default$.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.");
You may wish to indent some text when logging, use$.logGroup
to do so:
// log indented within (handles de-indenting when an error is thrown)await$.logGroup(async()=>{$.log("This will be indented.");await$.logGroup(async()=>{$.log("This will indented even more.");// do maybe async stuff here});});// or use $.logGroup with $.logGroupEnd$.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".
// Set the loggers. For example, log everything// on stdout instead of stderr$.setInfoLogger(console.log);$.setWarnLogger(console.log);$.setErrorLogger(console.log);// or a more advanced scenario$.setInfoLogger((...args:any[])=>{console.error(...args);// write args to a file here...};)
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 themaybe
variant functions.
Gets a string value from the user:
constname=await$.prompt("What's your name?");// or provide an object, which has some more optionsconstname=await$.prompt({message:"What's your name?",default:"Dax",// prefilled valuenoClear:true,// don't clear the text on result});// or hybridconstname=await$.prompt("What's your name?",{default:"Dax",});// with a character mask (for password / secret input)constpassword=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 pressesctrl+c
.
Gets the answer to a yes or no question:
constresult=await$.confirm("Would you like to continue?");// or with more optionsconstresult=await$.confirm({message:"Would you like to continue?",default:true,});// or hybridconstresult=await$.confirm("Would you like to continue?",{default:false,noClear:true,});
Gets a single value:
constindex=await$.select({message:"What's your favourite colour?",options:["Red","Green","Blue",],});
Gets multiple or no values:
constindexes=await$.multiSelect({message:"Which of the following are days of the week?",options:["Monday",{text:"Wednesday",selected:true,// defaults to false},"Blue",],});
You may wish to indicate that some progress is occurring.
constpb=$.progress("Updating Database");awaitpb.with(async()=>{// do some work here});
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 callpb.finish()
instead.
constpb=$.progress("Updating Database");try{// do some work here}finally{pb.finish();}
Set a length to be determinate, which will display a progress bar:
constitems=[/*...*/];constpb=$.progress("Processing Items",{length:items.length,});awaitpb.with(async()=>{for(constitemofitems){awaitdoWork(item);pb.increment();// or use pb.position(val)}});
The progress bars are updated on an interval (viasetInterval
) 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:
constpb=$.progress("Processing Items",{length:items.length,});pb.with(()=>{for(constitemofitems){doWork(item);pb.increment();pb.forceRender();}});
The path API offers an immutablePath
class viajsr:@david/path
, which is a similar concept to Rust'sPathBuf
struct.
// create a `Path`letsrcDir=$.path("src");// get information about the pathsrcDir.isDirSync();// false// do actions on itawaitsrcDir.mkdir();srcDir.isDirSync();// truesrcDir.isRelative();// truesrcDir=srcDir.resolve();// resolve the path to be absolutesrcDir.isRelative();// falsesrcDir.isAbsolute();// true// join to get other paths and do actions on themconsttextFile=srcDir.join("subDir").join("file.txt");textFile.writeTextSync("some text");console.log(textFile.readTextSync());// "some text"constjsonFile=srcDir.join("otherDir","file.json");console.log(jsonFile.parentOrThrow());// path for otherDirjsonFile.writeJsonSync({someValue:5,});console.log(jsonFile.readJsonSync().someValue);// 5
It also works to provide these paths to commands:
constsrcDir=$.path("src").resolve();await$`echo${srcDir}`;
Path
s can be created in the following ways:
constpathRelative=$.path("./relative");constpathAbsolute=$.path("/tmp");constpathFileUrl=$.path(newURL("file:///tmp"));// converts to /tmpconstpathStringFileUrl=$.path("file:///tmp");// converts to /tmpconstpathImportMeta=$.path(import.meta);// the path for the current module
There are a lot of helper methods here, so check thedocumentation on Path for more details.
Changing the current working directory of the current process:
$.cd("someDir");console.log(Deno.cwd());// will be in someDir directory// or change the directory of the process to be in// the directory of the current script$.cd(import.meta);
Sleeping asynchronously for a specified amount of time:
await$.sleep(100);// msawait$.sleep("1.5s");await$.sleep("1m30s");
Getting path to an executable based on a command name:
console.log(await$.which("deno"));// outputs the path to deno executable
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,// you may also specify an iterator here which is useful for exponential backoffdelay:"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");//=> 'Hello World'
Dax ships with a slightly less verbose wrapper aroundfetch
that will throw by default on non-2xx
status codes (this is configurable per status code).
Download a file as JSON:
constdata=await$.request("https://plugins.dprint.dev/info.json").json();console.log(data.plugins);
Or as text:
consttext=await$.request("https://example.com").text();
Or get the long form:
constresponse=await$.request("https://plugins.dprint.dev/info.json");console.log(response.code);console.log(awaitresponse.json());
Requests can be piped to commands:
constrequest=$.request("https://plugins.dprint.dev/info.json");await$`deno run main.ts`.stdin(request);// or as a redirect... this sleeps 5 seconds, then makes// request and redirects the output to the commandawait$`sleep 5 && deno run main.ts <${request}`;
See thedocumentation onRequestBuilder
for more details. It should be as flexible asfetch
, but uses a builder API (ex. set headers via.header(...)
).
You can have downloads show a progress bar by using the.showProgress()
builder method:
consturl="https://dl.deno.land/release/v1.29.1/deno-x86_64-unknown-linux-gnu.zip";constdownloadPath=await$.request(url).showProgress().pipeToPath();
The shell is cross-platform and uses the parser fromdeno_task_shell.
Sequential lists:
// result will contain the directory in someDirconstresult=await$`cd someDir ; deno eval 'console.log(Deno.cwd())'`;
Boolean lists:
// outputs to stdout with 1\n\2nawait$`echo 1 && echo 2`;// outputs to stdout with 1\nawait$`echo 1 || echo 2`;
Pipe sequences:
await$`echo 1 | deno run main.ts`;
Redirects:
await$`echo 1 > output.txt`;constgzippedBytes=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):
// result will contain the directory in someDirconstresult=await$`test=123 deno eval 'console.log(Deno.env.get('test'))'`;console.log(result.stdout);// 123
Shell variables (these aren't exported):
// the 'test' variable WON'T be exported to the sub processes, so// that will print a blank line, but it will be used in the final echo commandawait$`test=123 && deno eval 'console.log(Deno.env.get('test'))' && echo $test`;
Env variables (these are exported):
// the 'test' variable WILL be exported to the sub processes and// it will be used in the final echo commandawait$`export test=123 && deno eval 'console.log(Deno.env.get('test'))' && echo $test`;
Variable substitution:
constresult=await$`echo $TEST`.env("TEST","123").text();console.log(result);// 123
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
- Makesdirectories.- Ex.
mkdir -p DIRECTORY...
- Commonly used to make a directory and all itsparents with no error if it exists.
- Ex.
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 ashttps://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 throughsh
:sh -c <command>
(ex.sh -c cp source destination
). Obviously doing this won't work on Windows though.
Users on unix-based platforms often write a script like so:
#!/usr/bin/env -S deno runconsole.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`;
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
can be used for building up commands similar to what the tagged template$
does:
import{CommandBuilder}from"@david/dax";constcommandBuilder=newCommandBuilder().cwd("./subDir").stdout("inheritPiped")// output to stdout and pipe to a buffer.noThrow();constotherBuilder=commandBuilder.stderr("null");constresult=awaitcommandBuilder// won't have a null stderr.command("deno run my_script.ts").spawn();constresult2=awaitotherBuilder// will have a null stderr.command("deno run my_script.ts").spawn();
You can also register your own custom commands using theregisterCommand
orregisterCommands
methods:
constcommandBuilder=newCommandBuilder().registerCommand("true",()=>Promise.resolve({code:0}),);constresult=awaitcommandBuilder// now includes the 'true' command.command("true && echo yay").spawn();
RequestBuilder
can be used for building up requests similar to$.request
:
import{RequestBuilder}from"@david/dax";constrequestBuilder=newRequestBuilder().header("SOME_VALUE","some value to send in a header");constresult=awaitrequestBuilder.url("https://example.com").timeout("10s").text();
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 exportedbuild$
withCommandBuilder
and/orRequestBuilder
, 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";// creates a $ object with the provided starting environmentconst$=build$({commandBuilder:newCommandBuilder().cwd("./subDir").env("HTTPS_PROXY","some_value"),requestBuilder:newRequestBuilder().header("SOME_NAME","some value"),extras:{add(a:number,b:number){returna+b;},},});// this command will use the env described above, but the main// process won't have its environment changedawait$`deno run my_script.ts`;console.log(await$.request("https://plugins.dprint.dev/info.json").json());// use your custom functionconsole.log($.add(1,2));
This may be useful also if you want to change the default configuration. Another example:
constcommandBuilder=newCommandBuilder().exportEnv().noThrow();const$=build$({ commandBuilder});// since exportEnv() was set, this will now actually change// the directory of the executing processawait$`cd test && export MY_VALUE=5`;// will output "5"await$`echo $MY_VALUE`;// will output it's in the test dirawait$`echo $PWD`;// won't throw even though this command fails (because of `.noThrow()`)await$`deno eval 'Deno.exit(1);'`;
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.
constlocal$=$.build$();local$.setInfoLogger((...args:any[])=>{// a more real example might be logging to a fileconsole.log("Logging...");console.log(...args);});local$.log("Hello!");
Outputs:
Logging...Hello!
About
Cross-platform shell tools for Deno and Node.js inspired by zx.