Extending the deno cli using a shell function
2021-04-18 | 5 min read | Leer en españolI've been using deno
more and more these days and I must tell you they are still things that bug me. But I did manage to solve some of those problems by "adding" some sub-commands to the cli. No, it's not dark magic, it's just a trick that I learned a while ago and that I'm going to share with you today.
What do we need for this?
In theory any shell that allows you to declare functions should be enough. In this post I'll be showing you the examples using a POSIX compliant syntax, so they should work on shells like bash
, zsh
or ash
.
What do we want to achieve?
I would love for deno
to have some built-in equivalent of npm scripts
, but it doesn't seem like is going to happen any time soon. I wish I could do something like this.
deno start hello world
It would also be nice if I could initialize those scripts, because they have to live somewhere.
deno init
But as you know those sub-commands don't exists and that's what we are going to fix today (kinda).
Let us begin
The first step is to locate that one file you know your shell always execute. In zsh
there's ~/.zshrc
. bash
has ~/.bashrc
. Those two are the only ones I know, if you use any other shell try to find in its documentation the equivalent of that.
The definition
You ready now? Okay, now in that file you're going to write this.
deno()
{
echo "Hello"
}
Now if you restart your shell (or "reload" your configuration) and try to call deno
you should get Hello
. Isn't that nice? Awesome, but now we have created a problem, we can't use deno
. But fear not, we are on a good path.
Come back to me deno
If we want to call the deno
command we need to use the command known as command
. Something like this.
command deno --version
And that will get us all the info deno
has about itself.
deno x.x.x (release, x86_64-unknown-linux-gnu)
v8 x
typescript x.x
With this new knowledge we can solve our problem. Let's use that in our function.
deno()
{
echo "You're using"
command deno --version
}
And when we call it we should get this.
$ deno
You're using
deno x.x.x (release, x86_64-unknown-linux-gnu)
v8 x
typescript x.x
Going back to normal
We know how to call the deno
command but we still need to make our function behave like the "real deno
." We are going to do this in a way that can give us the oportunity to extend its behavior later on. For this we are going to use a case
statement.
deno()
{
local cmd=$1; shift;
case "$cmd" in
*)
command deno $cmd $@
;;
esac
}
The first thing this function does is assign the first parameter ($1
) to the variable cmd
and then remove it from the arguments list ($@
). After that we compare cmd
with a pattern. For now the only pattern we have is *
which is a wildcard that matches everything, is basically our default case. Let's make a test.
deno --version
It's getting better. But notice that if we try to use deno
without any arguments it'll give us an error. To fix that we are going to check if the first parameter is empty, and if it is we just call deno
.
deno()
{
if [ -z "$1" ];then
command deno
return
fi
local cmd=$1; shift;
case "$cmd" in
*)
command deno $cmd $@
;;
esac
}
Calling all sub-commands
We are right were we want to be, we can finally start adding our own sub-commands. Let's figure out how this would work.
deno()
{
if [ -z "$1" ];then
command deno
return
fi
local cmd=$1; shift;
case "$cmd" in
+ hello)
+ command deno eval "console.log('deno says hello')"
+ ;;
*)
command deno $cmd $@
;;
esac
}
Now call this new hello
command.
$ deno hello
deno says hello
So we know for sure this thing works.
deno scripts
We can work on our ad-hoc replacement for npm scripts. There is a common convention in node
where we use a start
command to "boot up" our application or project. Let's start with that.
deno()
{
if [ -z "$1" ];then
command deno
return
fi
local cmd=$1; shift;
case "$cmd" in
+ start)
+ command deno run --allow-run ./Taskfile.js start $@
+ ;;
*)
command deno $cmd $@
;;
esac
}
This command will execute a file called Taskfile.js
using deno
. The --allow-run
will let us use Deno.run
in our code to call external commands. It's test time.
Make that Taskfile.js
.
const cmd = ['echo', 'Taskfile: ', ...Deno.args];
Deno.run({ cmd });
Now use start
.
$ deno start hello
Taskfile: start hello
Just lovely. The next step will be to create a "smarter" Taskfile.js
, one that can call different tasks. I've done something like this in the past (check out the details of it in here), so I'm going to show you the code I would use.
const entrypoint = "./src/main.js";
run(Deno.args, {
start(...args) {
exec(["deno", "run", entrypoint, ...args]);
},
list() {
console.log('Available tasks: ');
Object.keys(this).forEach((k) => console.log(`* ${k}`));
},
});
function run([name, ...args], tasks) {
if(tasks[name]) {
tasks[name](...args);
} else {
console.log(`Task "${name}" not found\n`);
tasks.list();
}
}
async function exec(args) {
const proc = await Deno.run({ cmd: args }).status();
if (proc.success == false) {
Deno.exit(proc.code);
}
return proc;
}
With this we can execute the file ./src/main.js
using deno start
. But now we have a problem, we don't want to write all of that every time we create a new project. What we will do is create another command called init
to copy this template into our project folder.
deno()
{
if [ -z "$1" ];then
command deno
return
fi
local cmd=$1; shift;
case "$cmd" in
start)
command deno run --allow-run ./Taskfile.js start $@
;;
+ init)
+ cp /path/to/template/Taskfile.js ./
+ echo "Taskfile.js created"
+ ;;
*)
command deno $cmd $@
;;
esac
}
Everything is looking good. The last thing we will deal with is calling other tasks besides start
. With npm
we can call any script we want using npm run
, the equivalent for us will be called x
.
deno()
{
if [ -z "$1" ];then
command deno
return
fi
local cmd=$1; shift;
case "$cmd" in
+ x)
+ command deno run --allow-run ./Taskfile.js $@
+ ;;
start)
command deno run --allow-run ./Taskfile.js start $@
;;
init)
cp /path/to/template/Taskfile.js ./
echo "Taskfile.js created"
;;
*)
command deno $cmd $@
;;
esac
}
We are all set. If we had a "task" called test:api
we can invoke it using this.
deno x test:api
A little extra convenience
One more thing, I would like to have a way of calling a specific script and be able to call all my most used libraries without using URLs. We can do this with the help of import-maps, .json
files that can bind an "alias" to a URL.
I use one like this.
{
"imports": {
"@std/": "https://deno.land/std@0.93.0/",
"@npm/": "https://jspm.dev/",
"ansi-colors": "https://jspm.dev/ansi-colors@4.1.1",
"arg": "https://jspm.dev/arg@5.0.0",
"cheerio": "https://jspm.dev/cheerio@1.0.0-rc.5",
"exec": "https://deno.land/x/exec@0.0.5/mod.ts",
"ramda": "https://jspm.dev/ramda@0.27.1",
"@utils/": "/path/to/deno/utils/"
}
}
deno
can read this if we provide its path using the --import-map
flag. Let's add a command that uses that.
deno()
{
if [ -z "$1" ];then
command deno
return
fi
local cmd=$1; shift;
case "$cmd" in
x)
command deno run --allow-run ./Taskfile.js $@
;;
start)
command deno run --allow-run ./Taskfile.js start $@
;;
init)
cp /path/to/template/Taskfile.js ./
echo "Taskfile.js created"
;;
+ s|script)
+ command deno run --import-map="/path/to/deno/import-map.json" $@
+ ;;
*)
command deno $cmd $@
;;
esac
}
As with everything else, we need to test this thing. Create a script with this.
import dayjs from '@npm/dayjs';
import c from 'ansi-colors';
c.enabled = !Deno.noColor;
const date = dayjs().format('{YYYY} MM-DDTHH:mm:ss SSS [Z] A');
console.log(c.green(date));
Might want to specify the library version. Instead of using
@npm/dayjs
, use@npm/dayjs@1.10.4
And now call it.
$ deno script ./test.js
Download https://jspm.dev/dayjs@1.10.4
Download https://jspm.dev/npm:dayjs@1.10.4!cjs
{2021} 04-18T11:28:05 929 Z AM
Ha! Now I have all I want.
The final result
After all this process your deno
function should look like this.
deno()
{
if [ -z "$1" ];then
command deno
return
fi
local cmd=$1; shift
case "$cmd" in
x)
command deno run --allow-run ./Taskfile.js $@
;;
start)
command deno run --allow-run ./Taskfile.js start $@
;;
init)
cp /path/to/template/Taskfile.js ./
echo "Taskfile.js created"
;;
s|script)
command deno run --import-map="/path/to/deno/import-map.json" $@
;;
*)
command deno $cmd $@
;;
esac
}
Conclusion
So, what did we do today? We "hid" the deno
command inside a function in order to be able to add sub-commands that we created. We managed to build an equivalent to npm run
for deno
and finally we used import maps to simplify the declaration of our dependencies in a script. Not bad for a day's work.
Have any question? Feel free to leave a comment in one of these platform where I have shared this:
You can reach out to me on social media:
- Twitter @VonHeikemen_
- Bluesky @vonheikemen.bsky.social
- Mastodon @vonheikemen@hachyderm.io
Thank you for reading. If you find this article useful and want to support my efforts, buy me a coffee ☕