Doug is a modular build system that lets you to build your own zero-configuration commandline tools. Doug solves two problems:
-
Rather than install
webpack
,babel
,karma
,mocha
and all of the various tools just to get a project up and running, you only need to install one.create-react-app
is a very popular solution to this problem. -
When you want to take an existing Doug tool and customize or extend it, rather than fork it, you can simply create a new Doug tool depend on the other Doug tool. Thus you never have to deal with upstream merges and you end up with a much more maintainable ecosystem.
At it's core, Doug is just a pattern for building commandline tools using Vorpal that is easy to customize and extend.
doug-app
: build React applications with Webpack, Babel, Mocha, Karma, and PhantomJS.doug-lib
: build JavaScript libraries with Babel and Ava.
You don't need to use doug
to create your own zero-configuration build tool, but doug
does contain some code that will help you get going faster. That said, the internals are so simple I'm going to explain everything in this tutorial.
Background: For those less familiar with NPM or programming in general, you can create a "bin" property in a project's package.json
that let's you specify commandline aliases. If you install a package globally (npm install --global
) then you have access to these commands from the commandline anywhere. But if you install a package as a dependency of a project, you can access them from node_modules/.bin
. But these scripts are also accessible through the "scripts" property of a project's package.json
. Thus, a Doug tool should expose a bin script, then we can alias commands like npm start
to that script in our projects.
Doug is a pattern for creating commandline build tools that are extensible.
In this tutorial, we're going to run through an example of creating your own doug tool, hello-doug
, as a build tool for a project, hello-project
. There's just going to be one command that says "hello world" but it should give you and idea of all the pieces.
Any build tool needs to be configured. It's expected of Doug tools that all configurations are optional with reasonable default values. One way of configuring a build tool is with a JavaScript file in the root of the project, much like Webpack or ESLint. Some projects like to use the existing project.json
file for configuration but that limits your amount of expressiveness so I don't like to do that.
So suppose we create a doug.config.js
file that exports a configuration object at the root of our project:
// project/doug.config.js
module.exports = {
// configuration options here
}
Now, if we create a Doug tool for this project, we can access that configuration file through process.env.PWD
:
const config = require(`${process.env.PWD}/doug.config.js`)
Or an easier way is just to use the doug
:
const config = require('doug/config')
Using the doug script will also let you use your doug tool when it's globally installed using find-root
to resolve your project's root directory.
Another way we want configure build tools is with commandline options. Doug uses Vorpal for parsing and validating commandline arguments. Commandline options are usually specific to the command and let's define a command. I recommend breaking up commands into separate files so they're easily reusable if someone else wants to extend your Doug tool.
A command should have two properties -- options
should let you define the commandline options for the command, and action
is just a function that does whatever you want.
In the following example, we're defining a command that has a --name
option, and the action accepts the Doug config and the commandline options and will console.log
an appropriate greeting.
module.exports = {
options: (vorpal) => {
return vorpal
.option('-n, --name <name>', 'name of the person to greet')
},
action: (config, options) => {
console.log(`hello ${options.name || config.name || 'world'}`)
}
}
The vorpal
argrument passed into the options
function is going to be a Vorpal command so it's all the same syntax. The action
function will be called from the commandline script, but refactoring each command into this format makes it much easier for others to extend with their own tools.
Next thing we want to do is define the Vorpal command and, again, we'll want to separate this in it's own file to make it easier for extension later.
const hello = require('../commands/hello')
module.exports = (vorpal, config) => {
vorpal
.command('hello')
.description('a friendly greeting')
.use(hello.options)
.action(({options}) => {
return hello.action(config, options)
})
}
In the exported function, vorpal
is just a Vorpal instance so we can define a new command on it, and config
is the doug.config.js
or some defaults if that file doesn't exist. The .use
function is a small addition to Vorpal that makes it a bit more composable, applying all the options for the hello command to the Vorpal command. Then in the action, we get the CLI options and pass them on to the hello.action
.
The last piece of the puzzle is to create the actual commandline script. So let's do that:
#!/usr/bin/env node
const vorpal = require('doug/vorpal')
const config = Object.assign(require('./defaults'), require('doug/config'))
require('./cli/hello')(vorpal, config)
vorpal
.delimiter('hello-doug ❯❯❯')
.parse(process.argv)
The defaults file simply exports any default configuration which, in this case should just be an empty object. It may seem superfluous, but if we ever wanted to create new defaults, this allows other tools that extend this tool to get those changes without having to change their code.
module.exports = {}
The first line is called a shebang which lets your shell know which program is used to run this script. Then we're using doug
to load Vorpal with the .use
prototype method and the project's doug.config.js
. Note that the default config is just an empty object. Lastly, we require the hello command and parse the commandline arguments to set everything in motion.
Next, we need to make the script accessible from the outside. First we need to make it executable. This let's us run the script with ./bin.js hello
rather than having to call it with node bin.js hello
. It's as easy as chmod +x bin.js
. Then we'll want to create an NPM alias to that file so that we can use this script with hello-doug hello
rather than ./node_modules/hello-doug/bin.js hello
. And this can be done in the hello-doug/package.json
file which should look something like this:
{
"name": "hello-doug",
"private": true,
"version": "0.3.0",
"bin": {
"hello-doug": "./bin.js"
},
"devDependencies": {
"doug": "^0.3.0"
}
}
So now we have a build tool. It doesn't exactly build anything, but you could image this command building distribution files, deploying, publishing, running tests or starting up a development server.
Now let's use this build tool in one of our projects. All we need to do is create the project and add a script to that project's package.json
.
{
"name": "hello-project",
"version": "0.3.0",
"scripts": {
"hello": "hello-doug hello"
},
"devDependencies": {
"hello-doug": "^0.3.0"
}
}
Now there are a few ways to get this to work:
-
If you've published your
hello-doug
to NPM, you can justnpm install
and you're good to go. -
If you're building
hello-doug
locally in your filesystem, you'll need to link the projects together:
-
The easiest way is to put these packages in a
packages
directory and use a tool like Lerna:npm install --global lerna@prerelease lerna bootstrap
-
If you want these packages to remain in different repos or just want to do things manually, you'll have to use
npm link
.# create a symlink to hello-doug cd path/to/hello-doug npm link # link hello-doug to this project cd path/to/hello-project npm link hello-doug # install npm install
Once you've figured that out, you should be able to use it:
❯❯❯ npm start
> [email protected] start /Users/chetcorcos/code/doug/packages/hello-project
> hello-doug hello
hello-doug ❯❯❯
hello world
And we can pass options as well:
❯❯❯ npm start -- --name chet
> [email protected] start /Users/chetcorcos/code/doug/packages/hello-project
> hello-doug hello "--name" "chet"
hello-doug ❯❯❯
hello chet
And if we want to make some configurations specifically for our hello-project
, we can create a doug.config.js
file:
module.exports = {
name: 'doug',
}
And now when we run the command without any options, it should say "hello doug" rather than "hello world":
❯❯❯ npm start
> [email protected] start /Users/chetcorcos/code/doug/packages/hello-project
> hello-doug hello
hello-doug ❯❯❯
hello doug
By default, doug
adds a "shell" command to open up a Vorpal shell. In your package.json, you can add the following:
{
"scripts": {
"shell": "hello-doug shell"
}
}
Then when you run npm run-script shell
, you'll end up in the Vorpal shell where you can get some help about how to use the tool.
hello-doug ❯❯❯ help
Commands:
help [command...] Provides help for a given command.
exit Exits application.
shell open up a Vorpal shell
hello [options] a friendly greeting
hello-doug ❯❯❯ help hello
Usage: hello [options]
a friendly greeting
Options:
--help output usage information
-n, --name <name> name of the person to greet
You can run npm run-script shell
to open up a Vorpal shell and run commands like help
or help test
to more information about the commandline options.
One of the primary motivations for Doug is to create a tool that can be easily customized, extended, and maintained without foregoing the benefits of a zero-configuration build tool.
So suppose we want to extend our hello-doug
project to support another language, let's call it hola-doug
. We can create a new package that depends on both doug
and hello-doug
:
{
"name": "hola-doug",
"private": true,
"version": "0.3.0",
"bin": {
"hello-doug": "./bin.js"
},
"devDependencies": {
"doug": "^0.3.0",
"hello-doug": "^0.3.0"
}
}
Then we can create a command that adds this new functionality, extending the hello-doug
version:
const hello = require('hello-doug/commands/hello')
module.exports = {
options: (vorpal) => {
return vorpal
.use(hello.options)
.option('-s, --spanish', 'greet in spanish')
},
action: (config, options) => {
if (options.spanish) {
console.log(`hola ${options.name || config.name || 'mundo'}`)
} else {
return hello.action(config, options)
}
}
}
Then the cli/hello.js
file, defaults.js
file, and the bin.js
file are going to look very similar:
const hello = require('../commands/hello')
module.exports = (vorpal, config) => {
vorpal
.command('hello')
.description('a friendly greeting')
.use(hello.options)
.action(({options}) => {
return hello.action(config, options)
})
}
hola-doug/defaults.js
module.exports = {}
const vorpal = require('doug/vorpal')
const config = Object.assign(require('./defaults'), require('doug/config'))
require('./cli/hello')(vorpal, config)
vorpal
.delimiter('hola-doug ❯❯❯')
.parse(process.argv)
Now we can use npm start -- --name Jose --spanish
, for example. But we can extend these tools in many other ways. We change the default configs such as setting the name to Jose
.
module.exports = {
name: 'Jose',
}
We can also import commands from other Doug tools such as doug-app
:
const vorpal = require('doug/vorpal')
const config = Object.assign(
require('doug-app/defaults'),
require('hello-doug/defaults'),
require('./defaults'),
require('doug/config')
)
require('./cli/hello')(vorpal, config)
require('doug-app/cli/dev')(vorpal, config)
vorpal
.delimiter('hola-doug ❯❯❯')
.parse(process.argv)
Notice how we've merged all the default configs together. So there you have it. Fully extensible, zero-configuration build tools. It's really just a pattern.
In review of everything covered so far, let's talk about some key patterns that enable our tools to be extensible:
- define command as objects with
action
andoptions
fields in separate files - define the cli commands on vorpal in separate files that accept
vorpal
and theconfig
- define the defaults in a separate file so they can be required by tools that extend your tool
- every tool should have default values so that
doug.config.js
is never required
- every tool should have default values so that
- use
doug/vorpal
to importvorpal
with theCommand.use
extension - use
doug/config
so that this tool can be globally installed once I figure out how the config resolving should work - use
doug/resolve
so that projects can define relative paths in theirdoug.config.js
file - all async actions should return promises so they can be extended before or after they run
If you want to play around with the doug-app
or doug-lib
locally:
make install
make link
You need Docker to run tests locally:
make docker-setup
Then you can run tests:
make test-local