Building a CLI in Node for DevPail

Search for a command to run...

No comments yet. Be the first to comment.
I've grown tired of building, adapting and rebuilding my development tooling for each new project. DevPail is my solution, and I'd like to share how I built it. So I will.
This is the first in a series of articles about building DevPail, my general-purpose development environment tooling tool. Days gone by Oh, how I long for the days of yore. Especially Tuesdays. Tuesdays of yore were the best. Back then, building sof...
This is the first in a series of articles about building DevPail, my general-purpose development environment tooling tool. Days gone by Oh, how I long for the days of yore. Especially Tuesdays. Tuesdays of yore were the best. Back then, building sof...

Fact is, I should not have built this code. I'm sure that somewhere, there's a suitable NPM or PyPi package (or something in some other language) which would have worked out just fine. But nothing I saw on the first page of my search results seemed l...

This is (probably) the final installment in my series on Web Components, which you should totally check out if you haven't already, not only because I wanted to use the phrase "totally check out" in print, but also because those previous couple of ar...

In the continuing saga of our Web Component building journey, we now turn our attention to allowing our web components to be styled. In case you missed it, we've been building a "loading modal" web component, and we've added some customizability via ...

This is the second in a series of articles about building DevPail, my general-purpose development environment tooling tool. You don't have to read the previous article to learn something here, but the context may help you understand what I'm talking about.
In the first article, I introduced my solution to tooling churn, which is to keep all the tooling for each of my projects into an isolated, transportable environment I'm calling a DevPail. It's a Docker container where the tooling runs, coupled with a volume which holds all the tooling and development artifacts. However, the commands needed to use DevPail are fairly involved, so let's solve that issue today by creating a Command Line Interface (CLI) to handle all these tedious details.
DevPail's project directory so far is pretty bare:
devpail/
- imagesrc/
- homedir/
- Dockerfile
Since we've chosen to build or CLI in Node, we need to initialize a Node package:
$ cd devpail
$ mkdir src
$ npm init -y
Here we're creating the src directory to hold our CLI source code, and asked npm to create a package.json.
My habit is to build "kinda" code before actual code. It's real code, but at about 30,000 feet, with zero hope of running. This gives me the basic structure of the code, and a framework I can fill in with the gritty-kitty details as I go.
For DevPail, I'll need to be able to:
devpail --builddevpail --shelldevpailLet's create a file called src/cli.js to hold this code, and start building that framework. We know that the first thing we have to do is process those command line options, if any.
const status = processOptions()
if (status) {
console.log(`\nDevPail: ${status}\n`)
}
As promised, this code totally won't work yet. Let's move down one level:
function processOptions() {
var args = process.argv.slice(2)
if (args && args[0] === '--build') {
return buildImage()
} else if (args && args[0] === '--shell') {
return runShell()
} else {
return runContainer()
}
}
process.argv contains the entire command line, including the command used to start DevPail, so we slice it out, and check what's left for command line arguments. We always check that there are any arguments at all, and then if the first one is something we want to act on.
In our very simple case, the above works well enough. However, for anything even slightly more complex, you'll want to look into a package such as
yargsto handle your command line arguments.
Now that we have our basic flow in place, we can flesh out the details.
--buildWe've already know how to build the DevPail image from the command line:
docker build -t devpail imagesrc
Let's adapt that to Node. To run a docker, a shell command, we'll use the child_process module. And to tell docker where the Dockerfile is, we'll need to use the path module. Let's start by including those at the top of our script:
const { spawnSync } = require('child_process')
const path = require('path')
And then we'll Node-ify that command line:
function buildImage() {
console.log('DevPail: Building image "devpail:default"...')
spawnSync(
'docker',
[
'build',
'-t',
'devpail:default',
path.resolve(__dirname, '..', 'imagesrc')
],
{
shell: true,
stdio: 'inherit'
}
)
That path.resolve(...) call uses the inbuilt __dirname constant to get the location of the running script (src/cli.js), and then step up one level, and give us the imagesrc directory in which our Dockerfile is housed.
--build: ✔
I'm going to skip ahead to the third point in the list, for reasons which will become apparent.
You'll recall that rather lengthy command line which runs the container:
docker run -it --rm -v myproject-tooling:/home/pn/app -v ~/myproject:/home/pn/app/src devpail
Let's make this easier and a bit smarter.
We'll require that you run the devpail command (the one we're building now) from the root of the project you want to "DevPail-ize", as it were. This means that the current working directory should be the target project's name.
The solution will look much like that for --build, but with a few tweaks. Our first pass will look like:
function runContainer() {
var project = path.basename(process.cwd())
spawnSync(
'docker',
[
'run',
'-it',
'--rm',
'-v', `${project}-DevPail-Tooling:/home/pn/app`,
'-v', `${process.cwd()}:/home/pn/app/src`,
`devpail:default`
],
{
shell: true,
stdio: 'inherit'
}
}
Of course, you might use the same name for more than one project, but in different sub-trees, so we'll handle that case by adding a hash of the FULL path to the project's directory:
function createProjectName() {
return path.basename(process.cwd())
+ '-'
+ require('crypto')
.createHash('md5')
.update(process.cwd())
.digest('hex')
.slice(0, 8)
}
function runContainer() {
var project = createProjectName()
...
Docker will create a name for each running container from a couple random words which bear no relation to the purpose of the container. Let's make that name meaningful. And while we're at it, let's do the same for the container's hostname, which will be useful later on.
function runContainer() {
var project = createProjectName()
spawnSync(
'docker',
[
...
'--name', `${project}-DevPail`,
'--hostname', project,
`devpail:default`
],
...
}
devpail (no arguments): ✔
--shellWe can run a shell in our container by way of the --entrypoint argument to the docker command, as in:
docker -it --rm --entrypoint /bin/bash devpail
Of course, the above command line will also need all the other parameters we provided in the section above, and that sounds like --shell is just a special case of the no-arguments version of the devpail command.
Let's revisit processOptions() first:
function processOptions() {
...
} else if (args && args[0] === '--shell') {
return runContainer(['--entrypoint', '/bin/bash'])
} else {
return runContainer()
}
}
Now let's handle those arguments in runContainer():
function runContainer(opts = []) {
var project = createProjectName()
spawnSync(
'docker',
[
...
...opts,
`devpail:default`
],
...
}
--shell: ✔
At this point, we can easily build and run DevPail for a project, but it doesn't really accomplish anything just yet.
In the next installment of this series, we'll build out a pluggable GulpJS system that will actually do something useful.
I've made huge updates to the publicly available DevPail project, on both GitHub and NPM. It's evolved considerably since the first article, and I'm using it in production for several projects. So please feel to take a look at it. And then tune back in here to read more about the process of creating this tool!