Justfile for your project
See the end-result here
Background
@Graft we originally used a Makefile
to handle everyday developer workflow tasks, with help for Makefile
targets implemented similarly to this
As the project expanded, our Makefile
became increasingly unwieldy. Since we weren’t using make
as a traditional build system, instead of breaking it into smaller parts, we chose to replace it with the just command runner.
Wish list
For our in-house CLI, we wanted to implement the following features:
- A simple, repeatable process for adding new commands with subcommands and parameters
- A built-in help system
- Enforcement of standards for new CLI modules through linting
Notes
A comprehensive manual for just is available in book form here
This post does not aim to compare the advantages of different command runners; rather, it provides an example of a working Justfile setup.
Since Graft’s engineering team standardizes on macOS, the examples below will use macOS-specific tools.
Create bootstrap script
We’ll need a simple shell script to help first-time users install all the necessary dependencies. Create a new file called bootstrap.sh
with the code below, and make it executable by running chmod +x ./bootstrap.sh
.
#!/usr/bin/env zsh
#
# Install bare-minumum 'just' pre-requisites
if ; then
fi
Executing ./bootstrap.sh
will install the Homebrew package manager if it’s not already installed, and then use it to install just
, glow
(a CLI utility for rendering markdown), and gomplate
(a CLI template renderer).
Run ./bootstrap.sh
, then add export JUST_UNSTABLE=1
to your ~/.zshrc file
, and restart your terminal to apply the changes.
Create folder structure
Set up the following folder structure:
Create justfile
Add the following code to a new file named justfile
:
mod code 'recipes/modules/code/module.just'
import 'recipes/utils/utils.just'
import 'recipes/utils/help.just'
default:
@just --list
The root justfile
imports a (yet to be created) module from recipes/modules/code
and several utility recipes from recipes/utils
.
Create utility recipes
Global settings
Add the following code to a new file named recipes/utils/settings.just
:
# set shell
set shell := ["/bin/zsh", "-cu"]
# allow duplicate recipe names
set allow-duplicate-recipes := true
# load a dot-env file every time a recipe is run
set dotenv-load := true
# path to dot-env file to load
set dotenv-path := "recipes/.justenv"
# pass recipe arguments as positional arguments to commands. See https://github.com/casey/just?tab=readme-ov-file#positional-arguments
set positional-arguments := true
# reusable shebng line for recipes
shebang := '''
/usr/bin/env zsh
```[[ -n "${DEBUG:-}" ]] && echo 'setopt xtrace' || true```
'''
# reusable way to call just recipes from just recipes
just_call := "just --dotenv-path " + justfile_directory() / "recipes" / ".justenv"
Utility recipes
Create a reusable utility for checking recipe parameters in recipes/utils/params.just
with the following code:
[no-exit-message]
_params param_name param_value +supported_inputs="yes no":
#!{{ shebang }}
param_value=({{ supported_inputs }})
[[ ! " ${param_value[*]} " =~ " {{ param_value }} " ]] && \
{ echo "Unsupported {{ param_name }} value '{{ param_value }}'! Supported values are: ${param_value[@]}"; exit 1; } || true
Create a file called recipes/utils/utils.just
combining all utilities in one include with the following code:
import 'settings.just'
import 'params.just'
Finally, define environment variables to be set by just
for every executed recipe. Create a file called recipes/.justenv
with the following code:
JUST_CHOOSER=fzf
JUST_GLOW_HELP_WIDTH=140
Help system
just
already includes a convenient help system that displays a brief summary for each command, which is generated from comments directly above a recipe definition. We’ll expand on it by adding a system of detailed help markdown pages that users of our project can access by running just help [command] [sub-command]
Define ‘help’ just recipe
Define a help recipe in a file called recipes/utils/help.just
with the following code:
# command help
[no-exit-message]
help *command:
#!{{ shebang }}
subhelps=""
if (( $# == 0 )); then
for module in recipes/modules/*/ ; do
subhelps="${subhelps}\njust help ${module:t:r}"
done
elif (( $# == 1 )); then
if ! [ -d recipes/modules/$1/help ]; then
echo "No help for command $1"
exit 1
fi
for md_file in recipes/modules/$1/help/*.md; do
subhelps="${subhelps}\njust help $1 ${md_file:t:r}"
done
elif (( $# == 2 )); then
if ! [ -d recipes/modules/$1/help ]; then
echo "No help for command $1"
exit 1
fi
if ! [ -f recipes/modules/$1/help/$2.md ]; then
echo "No help for command $1 $2"
exit 1
fi
glow -w {{ env_var('JUST_GLOW_HELP_WIDTH') }} -p recipes/modules/$1/help/$2.md
exit 0
else
echo "Too many arguments! Use 'just help [command] [sub-command]'"
exit 1;
fi
echo -e "\nUse:\n${subhelps}\n"
Code in help
recipe will:
- Iterate over subfolders of
recipes/modules
and treate each subfolder as a root-level command. - Iterate
help
subfolder of each module and treat each found.md
file as a subcommand.
I.e. running:
just help code scaffold
will displayrecipes/modules/code/help/scaffold.md
help page usingglow
.just help code
will displayUse: just help code [.md file base name]
for each.md
file in ``recipes/modules/code/help`just help
will displayUse: just help [module folder base name]
for each folder inrecipes/modules
Creating new recipes
In our next step, we’re going to set up a code scaffold
recipe to handle all the repetitive tasks involved in adding a new recipe/module and subcommands. This will involve leveraging the gomplate
utility to generate the necessary source code from a collection of template files.
Root command/module template
Run mkdir -p recipes/modules/code/config
and add the following source code to a new file called recipes/modules/code/config/command.tpl
:
import '../../utils/utils.just'
import './{{ . }}.just'
[private]
default:
@just --justfile
Subcommand template
Add the following source code to a new file called recipes/modules/code/config/subcommand.tpl
:
# Fill out synopsis doc string
:
#!
Help page template
Add the following source code to a new file called recipes/modules/code/config/subcommand_help.tpl
:
# help page
# NAME
[]
# DESCRIPTION
TODO: Add description here
# EXAMPLE
```zsh
just ...
Fill me out!
```
# PARAMETERS
## Overview
TODO: add command parameter overview here
##
TODO: documentation for parameter
None
# FILES
TODO: related file locations or 'None'!
# Environment variables
TODO: related environment variables or 'None'!
# SEE ALSO
```zsh
TODO: related commands of note
```
# AUTHOR
[Your Name](mailto:you@domain.com)
Recipe
Add the following source code to a new file called recipes/modules/code/scaffold.just
:
# create new just command with subcommand and optional [params]
[no-cd]
[no-exit-message]
scaffold command subcommand *params:
#!{{ shebang }}
# check if command exists
command_exists="no"
if [ -d recipes/modules/{{ command }} ]; then
command_exists="yes"
if [ -f recipes/modules/{{ command }}/{{ subcommand }}.just ]; then
echo "'{{ command }}' subcommand '{{ subcommand }}' already exists!"
exit 1
fi
fi
# create command module folder
mkdir -p recipes/modules/{{ command }}/help
subcommands_array=("{{ subcommand }}")
# if command already exists, collect its sub-commands
if [[ "${command_exists}" = "yes" ]]; then
for just_file in recipes/modules/{{ command }}/*.just ; do
if [[ "${just_file:t:r}" != "module" ]]; then
subcommands_array+=(${just_file:t:r})
fi
done
fi
subcommands_json=$(jq --compact-output \
--null-input '$ARGS.positional' \
--args -- "${subcommands_array[@]}")
# template data to command's module.just
echo "{\"name\": \"{{ command }}\", \"subcommands\": ${subcommands_json}}" | \
gomplate -f recipes/modules/code/config/command.tpl \
-d module=stdin:///temp.json > recipes/modules/{{ command }}/module.just
# template data to subcommand just file
params_array=({{ params }})
params_json=$(jq --compact-output \
--null-input '$ARGS.positional' \
--args -- "${params_array[@]}")
echo "{\"name\": \"{{ subcommand }}\", \"params\": ${params_json}}" | \
gomplate -f recipes/modules/code/config/subcommand.tpl \
-d subcommand=stdin:///temp.json > recipes/modules/{{ command }}/{{ subcommand }}.just
# template data to subcommand help md file
echo "{\"parent\": \"{{ command }}\", \"name\": \"{{ subcommand }}\", \"params\": ${params_json}}" | \
gomplate -f recipes/modules/code/config/subcommand_help.tpl \
-d subcommand=stdin:///temp.json > recipes/modules/{{ command }}/help/{{ subcommand }}.md
# update justfile imports
if [[ "${command_exists}" != "yes" ]]; then
sed -i "1s,^,mod {{ command }} 'recipes/modules/{{ command }}/module.just'\n," justfile
fi
Update the just code scaffold
help page by adding the following code to a new file called recipes/modules/code/help/scaffold.md
:
* **
Running just code scaffold [root command] [subcommand] [list of subcommand parameters]
will:
- Create
recipes/modules/[root command]/module.just
if it does not exist - Update
recipes/modules/[root command]/module.just
with new[subcommmand].just
import if it exists - Create
recipes/modules/[root command]/[subcommmand].just
- Create
recipes/modules/[root command]/help/[subcommmand].md
- Add
mod [root command] 'recipes/modules/[root command]/module.just'
tojustfile
Binary deps recipe
We’re going to include a recipe to pin and install any binary dependencies that any of our current or future recipes rely on.
Run just code scaffold deps brew upgrade
to create a deps
root command with a brew
subcommand that has a upgrade
parameter.
Then modify the newly created recipe in recipes/modules/deps/brew.just
with the following source code:
# Install binary dependencies with homebrew bundle with optional [upgrade]
brew upgrade="no":
#!{{ shebang }}
# check if 'upgrade' parameter is valid
{{ just_call }} _params upgrade {{ upgrade }}
# make sure brew bundle is installed
echo "Checking 'brew bundle' ..."
brew bundle --help > /dev/null 2>&1
# install brew-bundled deps
if [[ "{{ upgrade }}" = "yes" ]]; then
brew bundle install -v --file {{ justfile_directory() }}/recipes/modules/deps/config/Brewfile
else
brew bundle install -v --no-upgrade --file {{ justfile_directory() }}/recipes/modules/deps/config/Brewfile
fi
Create a new file called Brewfile
in recipes/modules/deps/config
and update it with the following source code:
Finally, update the newly created recipes/modules/deps/help/brew.md
file with:
Run just deps brew
to verify the changes. You should end up with a new recipes/modules/deps/config/Brewfile.lock.json
file that contains the entire binary dependency tree with pinned versions. Make sure to commit it to your repository.
If you ever need to update versions of all the dependencies listed in Brewfile
you can do so by running just deps brew yes
Github Actions linter job
Finally, we’ll incorporate a Github actions workflow to ensure that the coding style of our just
components remains uniform. just
comes with an integrated linter through just --fmt --check
, and we’ll utilize this in conjunction with a Github Marketplace action that configures just
on a Github-hosted runner.
Run mkdir -p .github/workflows
and then create .github/workflows/just-lint.yaml
file using:
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
name: Lint just files
jobs:
just-lint:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install just
uses: extractions/setup-just@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Run just
shell: bash
run: |
# Run `--fmt` in 'check' mode. Exits with 0 if justfile is formatted correctly.
# Exits with 1 and prints a diff if formatting is required.
just --fmt --check --unstable
Done
To recap - you now have a starter justfile
setup that:
- Has a help file system through
just help ...
- Has binary dependency management through
Brewfile
andjust deps brew [update]
- Has new command scaffolding through
brew code scaffold ...
Enjoy extending this setup further to suit your specific needs!