Enj - noun
(/ɛndʒ/)A powerful CLI framework wrapped in an intuitive file based router.
- Simple to start, simple to use
- Easy to iterate, easy to configure
- Clean, readable file structure
- Extraordinarily powerful command definitions
- Doesn't support Windows yet
When I can prioritise it-
- Unironically rewrite in Rust or Zig, get execution time sub 10ms
- Enable writing commands any executable language
-
Create a directory in your project root called
enj
-
Add a file to that directory called
hello-world.js
containing the following// enj/hello-world.js export default (cmd) => cmd.action(() => { console.log(`Hello World!`) })
// enj/hello-world.js module.exports = (cmd) => cmd.action(() => { console.log(`Hello World!`) })
Enj also supports typescript with one simple installation.
Make sure your file extension is
.ts
.// enj/hello-world.ts import type { Cmd } from "enj" export default (cmd: Cmd) => cmd.action(() => { console.log(`Hello World!`) })
-
Run the command with Enj
npx enj hello-world # Hello World!
Enj can be installed globally or locally.
Install locally in team projects to ensure versions stay in sync.
npm install --save-dev enj
npm install --global enj
This file structure
enj
├── cowsay
│  ├── dragon.js
│  └── index.js
├── hello-world.js
├── _utils.js
└── index.js
Gives the following command structure
enj
enj cowsay
enj cowsay dragon
enj hello-world
Enj uses a file-based router to define the command structure.
To add the command enj hello-world
, we add a file to the enj root directory called hello-world.js
, or hello-world.ts
if you're using typescript.
Directories are used to create subcommands. For instance adding the directory cowsay/
and the file cowsay/dragon.js
, we create the command enj cowsay dragon
.
Enj respects index.[js|ts]
files. You can add configure the enj cowsay
command by adding the command file cowsay/index.js
.
Files and directories starting with _
will be ignored by Enj.
Unless it has been reconfigured, the Enj root directory is <DIR>/enj
, where DIR
is the nearest project root or the Current Working Directory.
module.exports = (cmd) =>
cmd
.argument("<name>", "Your name")
.option("-e, --excite", "Be excited")
.action((name, { excite }) => {
const punctuation = excite ? "!" : "."
console.log(`Hello ${name}${punctuation}`)
})
export default (cmd) =>
cmd
.argument("<name>", "Your name")
.option("-e, --excite", "Be excited")
.action((name, { excite }) => {
const punctuation = excite ? "!" : "."
console.log(`Hello ${name}${punctuation}`)
})
Make sure you are using typescript.
import type { Cmd } from "enj"
export default (cmd: Cmd) =>
cmd
.argument("<name>", "Your name")
.option("-e, --excite", "Be excited")
.action((name, { excite }) => {
const punctuation = excite ? "!" : "."
console.log(`Hello ${name}${punctuation}`)
})
As of v0.0.x
, Enj uses commander.js
for command definitions. This may change in future versions.
This means the cmd
argument that is passed into Enj command files is a commander Command
object.
NOTE: Because of this, you have control over more than just the command you are configuring in your command file. You could rename your command, add subcommands, return a different command, alias your command to something else, and much more. However this is undefined behaviour and really possibly could break things. Only get freaky with it if you know what you're doing.
Full documentation of the cmd
child methods can be found here.
Some useful examples:
cmd
.option("--long", "Long option")
.option("-s", "Short option")
.option("-b, --both", "Both long and short options")
.option("-n, --no-description")
.action(({ long, s, both, noDescription }) => { ... })
cmd
.option("-n, --no-arg", "No option-argument")
.option("-r, --required-arg <VALUE>", "Required option-argument")
.option("-o, --optional-arg [VALUE]", "Optional option-argument")
.action(({ arg, requiredArg, optionalArg }) => { ... })
Note that no-arg
is inverted and becomes arg
rather than noArg
. Read more here.
cmd
.option("-r, --required-arg <VALUE>", "Required") // required args don't take defaults
.option("-o, --optional-arg [VALUE]", "Optional with default", "default value")
.action(({ optionalArg }) => { ... })
Note that this only applies if the option is left out entirely. If it is used but empty, it will be true
.
cmd
.option("-r, --required-arg <VALUE>", "Required", parseFloat)
.option("-o, --optional-arg [VALUE]", "Optional with default", parseInt, 7)
.action(({ requiredArg, optionalArg }) => { ... })
const { Option } = require("enj");
...
cmd
.addOption(
new Option("-c, --cheese [CHEESE]", "add extra cheese")
.default("cheddar")
.choices(["cheddar", "brie", "gouda", "parmesan"])
)
.action(({ cheese }) => { ... })
Note for Typescript users: narrow the choices type by using as const
:
...
.choices(["cheddar", "brie", "gouda", "parmesan"] as const)
...
cmd
.argument("<required>", "Required argument")
.argument("[optional]", "Optional argument")
.argument("[no-description]")
.action((required, optional, noDescription) => { ... })
Note that unlike options, arguments are given to the action handler as separate arguments that precede the options object. Read more here.
cmd
.argument("<required>", "Required argument") // required args don't take defaults
.argument("[optional]", "Optional argument with default", "optional default")
.action((required, optional) => { ... })
cmd
.argument("<required>", "Required argument", parseFloat)
.argument("[optional]", "Optional argument", parseInt, 7)
.action((required, optional) => { ... })
const { Argument } = require("enj");
...
cmd
.addArgument(
new Argument("<size>", "Choose a size")
.default("small")
.choices([
"small",
"medium",
"large",
]),
)
.action((size) => { ... })
Note for Typescript users: narrow the choices type by using as const
:
...
.choices([
"small",
"medium",
"large",
] as const),
...
This is only scratching the surface. Read the commander.js examples and docs for more.
Enj can be configured in all of three ways
- Environment variables
- Run script args
- Config files
Config | Default | Environment varable | Run script arg | Config file value |
---|---|---|---|---|
Config file path | Found dynamically | ENJ_CONFIG_FILE |
configFile |
N/A |
Commands root directory | <PROJECT_ROOT>/enj |
ENJ_ROOT_DIR |
rootDir |
rootDir |
Paths can be absolute. If they are relative, Enj does it's best to resolve them to something sensible. This depends on the configuration method.
Environment varable | Run script arg | Config file value | |
---|---|---|---|
Relative to the directory | The command was called in | Of the run script | The config is in |
Add an enj
object to your package.json
:
"enj": {
"rootDir": "./commands"
},
Supported config files are
.enjrc
.enjrc.[format]
enj.config.[format]
.config/enjrc
.config/enjrc.[format]
Supported formats are json|yaml|yml|js|ts|mjs|cjs
If no config file is defined explicitly, Enj will search for a config file all the way down to your home directory.
If you are using a custom run script to configure Enj, config search will start from the directory your script is in.
Otherwise it will fall back through the following locations:
- Environment variable
ENJ_ROOT_DIR
- Run script config
rootDir
- The nearest nodejs project root
- The Current Working Directory
After arriving at your home directory, if no config files are found, the global configuration directory is also checked. The search location is defined by env-paths (without suffix.)
This directory is searched for the following files:
config
config.[format]
If installed locally
npx enj -h
If installed globally
enj -h
A custom CLI can be created with custom configuration.
Create a cli file and make it executable (use any name you like, cli
is an example):
touch ./cli && chmod +x ./cli
// ./cli
#! /usr/bin/env node
const { run } = require('enj')
run()
// ./cli
#! /usr/bin/env node
import { run } from 'enj'
run()
Make sure you are using typescript.
IMPORTANT: Note the use of npx tsx
instead of node
in the shebang.</sub>
// ./cli
#! /usr/bin/env npx tsx
import { run } from 'enj'
run()
The run()
function takes an optional config object:
run({
rootDir: 'commands',
})
Run the script from the Commandline (if you called it something else, use that name.)
./cli -h
Why use a run script? You may want to publish your own CLI, rename Enj locally, or avoid using configuration files. These are all valid reasons to use a run script.
You can of course use a package.json script to call enj.
"scripts": {
...
"hello": "enj hello-world",
...
},
Using typescript with Enj is as easy as installing tsx
either globally or locally.
npm install --save-dev tsx
// enj/hello-world.ts
import type { Cmd } from "enj"
export default (cmd: Cmd) =>
cmd.action(() => {
console.log("Hello World!")
})
Enj still supports js files if you install tsx, so you can incrementally adopt typescript if necessary.
Enj also supports both ESM and CommonJS modules in JS, meaning you don't need to choose.