Justfile for your project

  • Updated on 14th Oct 2024
TLDR

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

just manual

A comprehensive manual for just is available in book form here

other command runners

This post does not aim to compare the advantages of different command runners; rather, it provides an example of a working Justfile setup.

macOS-centric

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 [[ $(command -v brew) == "" ]]; then
  echo "Installing Hombrew"
  /bin/zsh -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi

brew install just glow fzf jq

echo "Please add the following to your '~/.zshrc': 'export JUST_UNSTABLE=1'"

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:

recipes
├── modules
   └── code
       └── help
└── utils

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 display recipes/modules/code/help/scaffold.md help page using glow.
  • just help code will display Use: just help code [.md file base name] for each .md file in ``recipes/modules/code/help`
  • just help will display Use: just help [module folder base name] for each folder in recipes/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'
{{- range (datasource "module").subcommands }}
import './{{ . }}.just'
{{- end }}

[private]
default:
  @just --justfile {{"{{"}} justfile() {{"}}"}} --list {{ (datasource "module").name }}

Subcommand template

Add the following source code to a new file called recipes/modules/code/config/subcommand.tpl:

# Fill out {{ (datasource "subcommand").name }} synopsis doc string
{{ (datasource "subcommand").name }} {{- range (datasource "subcommand").params -}}{{" "}}{{ . }}{{- end -}}:
  #!{{"{{"}} shebang {{"}}"}}
  echo "Implement me!"

Help page template

Add the following source code to a new file called recipes/modules/code/config/subcommand_help.tpl:

# {{ (datasource "subcommand").parent }} {{ (datasource "subcommand").name }} help page

# NAME

{{ (datasource "subcommand").parent }} {{ (datasource "subcommand").name }} {{- range (datasource "subcommand").params -}}{{" "}}[{{ . }}]{{- end}}

# DESCRIPTION

TODO: Add description here

# EXAMPLE

```zsh
just {{ (datasource "subcommand").parent }} {{ (datasource "subcommand").name }} ...
Fill me out!
```

# PARAMETERS

{{ if (gt ((datasource "subcommand").params | len) 0) }}
## Overview

TODO: add command parameter overview here

{{range (datasource "subcommand").params }}
## {{ . }}

TODO: documentation for {{ . }} parameter

{{ end }}
{{ else }}
None
{{ end }}

# 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:

# code scaffold help page

# NAME

code scaffold [command] [subcommand] [params...]

# DESCRIPTION

Create scaffolding for a new just module with recipes

# EXAMPLE

```zsh
just code scaffold deps bin
just code scaffold code lint dryrun path
```

# PARAMETERS

## Overview

* `command`       **Required**  
* `subcommand`    **Required**  
* `params..`      Optional   

## command

Top level command name to be created or added to.

## subcommand

Sub-command name to be created under `command`

## params

Array of `subcommand` parameters

# FILES

`command`, `subcommand` and help file templates are located under `recipes/modules/code/config`


# AUTHOR

[Your Name]mailto:you@domain.com

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' to justfile

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:

tap "aws/tap"

brew "awscli"
brew "fzf"
brew "gh"
brew "glow"
brew "gomplate"
brew "jq"
brew "just"

Finally, update the newly created recipes/modules/deps/help/brew.md file with:

# deps brew help page

# NAME

deps brew [upgrade]

# DESCRIPTION

Install binary dependencies using `brew bundle`

# EXAMPLE

```zsh
just deps brew
just deps brew yes
```

# PARAMETERS


## Overview

`upgrade` Optional, Supported values are `yes` or `no` (default)


## upgrade

Upgrade installed dependencies

# FILES

None

# Environment variables

None

# AUTHOR

[Your Name]mailto:you@domain.com

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 and just deps brew [update]
  • Has new command scaffolding through brew code scaffold ...

Enjoy extending this setup further to suit your specific needs!