4 stable releases

1.3.0 Jun 14, 2023
1.2.0 Jun 5, 2023
1.1.0 Jun 1, 2023
1.0.0 May 23, 2023

#154 in Build Utils

28 downloads per month

MIT license

165KB
3.5K SLoC

mom-task

build codecov License: MIT

Task runner for teams and individuals. Written in Rust.

Index

Inspiration

Inspired by different tools like cargo-make, go-task doskey, bash and docker-compose.

This project is a fork of my previous project yamis, which started as a simple task runner for work and personal projects, also as a way to learn Rust. I decided to fork it to drop some unnecessary complexity and improve the YAML file structure, i.e. to more closely follow existing tools. This also uses a more familiar name for the binary, pun intended.

Installation

Homebrew

If you have homebrew installed, you can install mom with:

$ brew tap adrianmrit/mom
$ brew install mom

With cargo

If you have cargo installed, you can install mom with the following command:

$ cargo install mom-task

Pro-tip: make sure ~/.cargo/bin directory is in your PATH environment variable.

Binary releases

Binaries are also available for Windows, Linux and macOS under releases. To install, download the zip for your system, extract, and copy the binary to the desired location. You will need to ensure the folder that contains the binary is available in the PATH.

JSON Schema

The JSON schema for the task files can be found here.

You can configure your IDE to use the schema for autocompletion and validation.

VSCode

To integrate the schema with VSCode, you can use the YAML extension. Once installed, you can add the following to your settings.json file:

"yaml.schemas": {
  "https://raw.githubusercontent.com/adrianmrit/mom/main/json-schema/mom.json": [
    "mom.*.yml",
    "mom.*.yaml",
    "mom.yml",
    "mom.yaml",
  ]
}

You can also add the schema to a specific file by adding the following to the top of the file:

# yaml-language-server: $schema=https://raw.githubusercontent.com/adrianmrit/mom/main/json-schema/mom.json
version: 1

Quick start

Create a file named mom.root.yml in the root of your project.

Here is a very basic example of a task file:

# mom.root.yml
version: 1

vars:
  greeting: Hello World

tasks:
  hi:
    cmds:
      - echo {{ vars.greeting }}
  
  hi.windows:
    cmds:
      - echo {{ vars.greeting }} from Windows
  
  sum:
    cmds:
      - echo "{{ args.0 }} + {{ args.1 }} = {{ args.0 | int + args.1 | int }}"
  
  swear:
    condition: |
      {{ input(label="Are you sure you want to say that? (yes/no)") | lower == 'yes' }}
    cmds:
      - echo "!@#$%^&*()"

After having a mom file, you can run a task by calling mom, the name of the task, and any arguments, i.e. mom hi. Arguments can be passed right after the task name, either by name or position, i.e. mom sum 1 2.

Usage

Command line options

If you want to pass command line options to mom itself, they must be passed before the task name, any argument after the task name will be considered an argument for the task. For example, if you want to run global tasks, you need to pass the -g or --global flag before the task name, i.e. mom -g say_hi, not mom say_hi -g.

Some of the command line options can be combined, i.e. mom -gt (or mom -g -t) will give you a list of global tasks, and mom -gl will give you the location of the global task file.

If you want to use a non standard task file, you can use the -f or --file option, i.e. mom -f my_tasks.yml say_hi.

To run a task in dry mode, i.e. without executing any commands, you can use the --dry flag, i.e. mom --dry say_hi.

You can see some extra command line options by running mom -h or mom --help.

Task files

The tasks are defined using the YAML format.

When invoking a task, starting in the working directory and continuing to the root directory, the program will look configuration files in a certain order until either a task is found, a mom.root.{yml,yaml} file is found, or there are no more parent folders (reached root directory). The name of these files is case-sensitive in case-sensitive systems, i.e. mom.root.yml will not work in linux.

The priority order is as follows:

  • mom.private.yml: Should hold private tasks and should not be committed to the repository.
  • mom.private.yaml: Same as above but for yaml format.
  • mom.yml: Should be used in sub-folders of a project for tasks specific to that folder and sub-folders.
  • mom.yaml: Same as above but for yaml format.
  • mom.root.yml: Should hold tasks for the entire project.
  • mom.root.yaml: Same as above but for yaml format.

An especial task file can be defined at ~/mom/mom.global.yml or ~/mom/mom.global.yaml for global tasks. To run a global task, you need to pass the --global or -g flag, i.e. mom -g say_hi. This is useful for personal tasks that are not related to a specific project.

Tasks can also be defined in a different file by passing the --file or -f flag, i.e. mom -f my_tasks.yml say_hi.

While you can add any of the two formats, i.e. mom.root.yml and mom.root.yaml, it is recommended to use only one format for consistency and to avoid confusion.

Common Properties

The following properties can be defined in the task file or in the task itself. The value defined in the task takes precedence over the value defined in the file.

  • wd: The working directory.
  • env: Environment variables.
  • dotenv: File or list of files containing environment variables.
  • vars: Variables.
  • incl: Templates that can be included/imported in the Tera template engine.

wd

The wd property is used to define the working directory. Defaults to the current working directory. It can be defined at the file or task, with the value defined in the task taking precedence over the value defined in the file.

The path can be absolute or relative to the location of the file. To set the working directory to the location of the file, use wd: ".". Alternatively, to set it to the directory where the command was executed, use wd: "". Note that while in the task you can set wd: null, it will be treated as if wd was not defined, therefore inheriting from the parent.

env

The env property is used to define environment variables that will be available to all tasks in the file. The value of the property is a map of key-value pairs, where the key is the name of the environment variable, and the value is the value of the environment variable.

The value defined in the executed task takes precedence over the value defined in the file.

Generally environment variables can be accessed in three ways, with tera tags, i.e. {{ env.VAR }}, wherever they are supported, with the tera function get_env, i.e. {{ get_env(name="VAR", default="default") }}, or through shell expansion, i.e. $VAR or ${VAR}. However note that shell expansion is not available inside tera tags.

Note that when accessing environment variables with tera tags (i.e. {{ env.VAR }}), system environment variables are not available, only the ones defined in the file or task. To access system environment variables, use the get_env function or use shell expansion.

See also:

dotenv

The dotenv property is used to define environment variables that will be available to all tasks in the file. The value of the property is a string, or list of strings containing the path to the files containing the environment variables. The path can be absolute or relative to the location of the file.

The value defined in the env property take precedence over the value defined using the dotenv property.

vars

The vars property is used to define variables that will be available to all tasks in the file. This behaves like the env property, but the variables are not exported to the environment, and can be more complex than strings.

For example, you can define a variable like this:

vars:
  user:
    age: 20
    name: John

And then use it in a task like this:

tasks:
  say_hi:
    cmd: echo "Hi, {{ vars.user.name }}!"

incl

The incl property is used to define Tera includes/templates that will be available to all tasks in the file. The value of the property is a map of key-value pairs, where the key is the name of the template, and the value is the template itself. The template can then be accessed in a task with the name incl.<name>.

Templates can include other templates, but the order in which they are defined matters.

For example, you can define a template like this:

incl:
  say_hi: "Hi from {{ TASK.name }}!"
  say_bye: "Bye from {% include "incl.say_hi" %}!"

However, the following will not work:

incl:
  say_bye: "Bye from {% include "incl.say_hi" %}!"
  say_hi: "Hi from {{ TASK.name }}!"

Templates can also be defined in the task, and they will take precedence over the templates defined in the file.

Templates can be also used to define macros. See the also the include documentation for Tera.

Tasks File Properties

Besides the common properties, the following properties can be defined in the task file:

  • tasks: The tasks defined in the file.
  • version: The mayor version of the file. Although not used at the moment, it is required for future compatibility. The version can be a number or string. At the moment of writing this, the version should be 1.
  • extend: Mom files to inherit from.

tasks

The tasks property is used to define the tasks in the file. The value of the property is a map of key-value pairs, where the key is the name of the task, and the value is the task definition.

The name of the task must start with an ascii alpha character or underscore, followed by any number of letters, digits, - or _. The name may also end with .windows, .linux or .macos to define an OS-specific task. You can choose, as a convention, to name private tasks with a leading underscore, i.e. _private_task.

File extend

In the file, the extend property is used to define the mom files to inherit from. It might be a path or a list of paths relative to the location of the file, or an absolute path. For example:

version: 1

# Extend from a file in the same directory
extend: mom.base.yml

# Extend from a file in a subdirectory
extend: base/mom.base.yml

# Extend from multiple files
extend:
  - mom.base.yml
  - base/mom.base.yml

The inherited values are:

Values merged (with the file values taking precedence) are:

dotenv is loaded and merged with the env in the same file before extending from a file or merging into the parent file. Which means it is treated as part of the env

Task Properties

Besides the common properties, the task can have the following properties:

  • help: The help message.
  • script: The script to execute.
  • script_runner: A template to parse the script program and arguments.
  • script_extension: The extension of the script file.
  • script_ext: Alias for script_extension.
  • cmds: The commands to execute.
  • program: The program to execute.
  • args: The arguments to pass to the program.
  • args_extend: The arguments to pass to the program, appended to the arguments from the base task, if any.
  • args+: Alias for args_extend.
  • linux: A version of the task to execute in linux.
  • windows: A version of the task to execute in windows.
  • mac: A version of the task to execute in mac.
  • private: Whether the task is private or not.
  • extend: Tasks to inherit from.

help

The help property is used to define the help message for the task. The value of the property is a string containing the help message.

Unlike comments, help will be printed when running mom -i <TASK>.

condition

The condition property is used to define a condition to execute the task. The value of the property is a string containing a Tera template. If the template evaluates to true, the task will be executed, otherwise it will be skipped.

Only true (case insensitive) values are considered true, all other values are considered false. This is because Tera conditions will return ether true or false.

Example:

tasks:
  say_hi:
    condition: "{{ env.ENVIRONMENT == 'production' }}"  # will evaluate to true if ENVIRONMENT is production, false otherwise
    script: echo "Hi!"

This can also be handy to choose between different tasks depending on some condition, i.e.

tasks:
  greet:
    cmds:
      - task:
          condition: "{{ env.ENVIRONMENT == 'production' }}"
          script: echo "Hi!"
      - task:
          condition: "{{ env.ENVIRONMENT != 'production' }}"
          script: echo "Bye!"

Script

⚠️Warning: DO NOT PASS SENSITIVE INFORMATION AS PARAMETERS IN SCRIPTS. Scripts are stored in a file in the temporal directory of the system and is the job of the OS to delete it, however it is not guaranteed when or if that would be the case. So any sensitive argument passed could be persisted indefinitely.

The script value inside a task will be executed in the command line (defaults to cmd in Windows and bash in Unix). Scripts can spawn multiple lines, and contain shell built-ins and programs.

The generated scripts are stored in the temporal directory, and the filename will be a hash so that if the script was previously called with the same parameters, we can reuse the previous file, essentially working as a cache.

script_runner

The script_runner property is used to define the template to parse the script program and arguments. Must contain a program and a {{ script_path }} template, i.e. python {{ script_path }}. Arguments are separated in the same way as args. This parameter supports expanding environment variables like `$A

script_extension

The script_extension property is used to define the extension of the script file. I.e. py or .py for python scripts.

Program

The program value inside a task will be executed as a separate process, with the arguments passed on args, if any.

Args

The args values inside a task will be passed as arguments to the program, if any. The value is a string containing the arguments separated by spaces. Values with spaces can be quoted to be treated as one, i.e. "hello world". Quotes can be escaped with a backslash, i.e. \".

Args Extend

The args_extend values will be appended to args (with a space in between), if any. The value is a string in the same form as args.

Cmds

The cmds value is a list of commands to execute. Each command can be either a string, or a map with a task key.

If the command is a string, it will be executed as a program, with the first value being the program, and the rest being the arguments. Arguments are separated in the same way as args. For convenience, echo is a built-in in mom, so that the same command works properly in Windows and Unix.

If the command is a map, the value of task can be either the name of a task to execute, or the definition of a task to execute.

Example:

tasks:
  say_hi:
    script: echo "hi"

  say_bye:
    cmds:
      - echo "bye"
  
  greet:
    cmds:
      - python -c "print('hello')"
      - task: say_hi
      - task:
          extend: say_bye

Private

The private value is a boolean that indicates if the task is private or not. Private tasks cannot be executed directly, but can be inherited from.

Task extend

In the task, the extend property is used to define the tasks to inherit from. It might be a string or a list of strings. For example:

tasks:
  base_task:
    program: echo
    args: "Hello, world!"
  task:
    extend: base_task

  other_task:
    extend:
      - base_task
      - task

The tasks are merged, with the parent task taking precedence over the base task.

The inherited values are:

Values merged (with the parent values taking precedence) are:

Just like in the file, dotenv is loaded and merged with the env in the same task before extending from a task or merging into the parent task. Which means it is treated as part of the env in the task.

Values not inherited are:

OS specific tasks

You can have a different OS version for each task. If a task for the current OS is not found, it will fall back to the non os-specific task if it exists. I.e.

tasks:
  ls:
    script: "ls {{ args.0 }}"

  ls.windows:
    script: "dir {{ args.0 }}"

Os tasks can also be specified in a single key, i.e. the following is equivalent to the example above.

tasks:
  ls: 
    script: "ls {{ args.0 }}"

  ls.windows:
    script: "dir {{ args.0 }}"

Note that os-specific tasks do not inherit from the non-os specific task implicitly, if you want to do so, you will have to define extend explicitly, i.e.

tasks:
  ls:
    env:
      DIR: "."
    script: "ls {{ env.DIR }}"

  ls.windows:
    extend: ls
    script: "dir {{ env.DIR }}"

Passing arguments

Arguments for tasks can be either passed as a key-value pair, i.e. --name "John Doe", or as a positional argument, i.e. "John Doe".

Named arguments must start with one or two dashes, followed by an ascii alpha character or underscore, followed by any number of letters, digits, - or _. The value will be either the next argument or the value after the equals sign, i.e. --name "John Doe", --name-person1="John Doe", -name_person1 John are all valid. Note that "--name John" is not a named argument because it is surrounded by quotes and contains a space, however "--name=John" is valid named argument.

The exported variables are:

  • args: The arguments passed to the task. If the task is called with mom say_hi arg1 --name "John", then args will be ["arg1", "--name", "John"].
  • kwargs: The keyword arguments passed to the task. If the task is called with mom say_hi --name "John", then kwargs will be {"name": "John"}. If the same named argument is passed multiple times, the value will be the last one.
  • pkwargs: Same as kwargs, but the value is a list of all the values passed for the same named argument.
  • env: The environment variables defined in the task. Note that this does not includes the environment variables defined in the system. To access those, use {{ get_env(name=<value>, default=<default>) }}.
  • vars: The variables defined in the task.
  • TASK: The task object and its properties.
  • FILE: The file object and its properties.

Named arguments are also treated as positional arguments, i.e. if --name John --surname=Doe is passed, {{ args.0 }} will be --name, {{ args.1 }} will be John, and {{ args.2 }} will be --surname="Doe". Thus, it is recommended to pass positional arguments first.

In you want to pass all the command line arguments, you can use {{ args | join(sep=" ") }}, or {% for arg in args %} "{{ arg }}" {% %} if you want to quote them.

See also:

Env and vars inheritance

When using the same environment variables (env) and variables (vars) values exist in multiple places, the most specific value will take precedence. For example, values defined using env take precedence over values defined using dotenv, and vars or env defined in a task take precedence over the values defined in the file.

For example, if you have the following file:

version: 1

# Default values. The tasks can override these values.
env:
  ENV1: "env1"
  ENV2: "env2"

vars:
  VAR1: "var1"
  VAR2: "var2"

tasks:
  test1:
    env:
      ENV2: "test1_env2"
      ENV3: "test1_env3"
    vars:
      VAR2: "test1_var2"
      VAR3: "test1_var3"
    cmds:
      - echo "{{ env.ENV1 }} {{ env.ENV2 }} {{ env.ENV3 }}"
      - echo "{{ vars.VAR1 }} {{ vars.VAR2 }} {{ vars.VAR3 }}"
      
      # env and vars from the parent will take precedence
      - task: test2
      
      # This subtask will inherit the env and vars from the parent
      # but its own bases, envs and vars will take precedence
      - task:
          # Bases will take precedence over the parent task
          extend: test2

          # env and vars take precedence over the parent task and the bases
          env:
            ENV2: "subtask_env2"
          vars:
            VAR2: "subtask_var2"
  
  test2:
    env:
      ENV2: "test2_env2"
      ENV3: "test2_env3"
    vars:
      VAR2: "test2_var2"
      VAR3: "test2_var3"
    cmds:
      - echo "{{ env.VAR1 }} {{ env.VAR2 }} {{ env.VAR3 }}"
      - echo "{{ vars.VAR1 }} {{ vars.VAR2 }} {{ vars.VAR3 }}"

The output will be (excluding debug output):

$ mom test1
env1 test1_env2 test1_env3
var1 test1_var2 test1_var3
env1 test1_env2 test1_env3
var1 test1_var2 test1_var3
env1 subtask_env2 test2_env3
var1 subtask_var2 test2_var3

This might be a bit confusing, so let's explain the output:

env1 test1_env2 test1_env3
var1 test1_var2 test1_var3

This is the output of the first two commands in the test1 task. ENV1 and VAR1 are only defined in the file, while the task overrides ENV2, ENV3, VAR2 and VAR3.

env1 test1_env2 test1_env3
var1 test1_var2 test1_var3

This is the output of the third command in the test1 task, which calls test2. Again, ENV1 and VAR1 are only defined in the file. Even though test2 overrides ENV2, ENV3, VAR2 and VAR3, the values defined in test1, the parent task, take precedence.

env1 subtask_env2 test2_env3
var1 subtask_var2 test2_var3

This is the output of the fourth command in the test1 task, which calls a subtask. ENV1 and VAR1 are only defined in the file. While it might seem like we are calling task2, we actually defined a new task that inherits from task2 and overrides ENV2 and VAR2. Therefore, the values inherited from task2 will take precedence over the parent task.

Shell expansion

Some task properties support shell-like expansion. The following characters are expanded:

  • ~: The home directory.
  • $VAR: The value of the environment variable VAR.
  • ${VAR}: The value of the environment variable VAR.

Note that while environment variables can be expanded this way, they will not be available in tera templates. I.e. {{ $VAR }} will raise an error. You can use instead {{ env.VAR }}. See also [env] (#env).

The following task properties support shell expansion:

Tera template engine

The template engine used is Tera. The syntax is based on Jinja2 and Django templates. The docs contain all the information needed and is very straightforward, so won't repeat it here. Just ignore the rust specific parts.

See also:

Mom filters

exclude

Exclude a value from a list or map. The value can be a string, or a list of strings.

Example:

tasks:
  test:
    vars:
      var1: "value1"
      var2: "value2"
      var3: "value3"
    script: echo "{{ env | exclude(val='var2') | json_encode() }} {{ [1, 2, 3] | exclude(val=2) }}"

Output:

$ mom test
{"var1": "value1", "var3": "value3"} [1, 3]

Mom functions

input

Asks for user input. Takes a label and a default argument. While label must be a string, default can be any type.

An if argument can also be provided, which must be a boolean, and must be accompanied by a default argument. If if is false, the default argument will be returned without asking for user input. This is a shorthand for if statements in the template.

Example:

tasks:
  test:
    script: echo "{{ input(label='What is your name?', default='John Doe') }}"

Output:

$ mom test
What is your name? [John Doe]: 
tasks:
  test:
    script: echo "{{ input(label='What is your name?', default='John Doe', if=args is containing("--default")) }}"
$ mom test --default
John Doe

password

⚠️Warning: If used in a script, the password will still be added in plain text to the script file. Use with caution.

Like input, but the input is not echoed to the terminal.

get_env

We override the default implementation so that this method also returns the environment variables defined in the mom file, which take precedence over system environment variables. Takes a name argument which must be a string, and an optional default argument which can be any type.

Example:

tasks:
  test:
    script: echo "{{ get_env(name='VAR1', default='default') }}"

Output:

$ mom test
default
$ VAR1="value1"
$ mom test
value1

Contributing

Contributions welcome! Please read the contributing guidelines first.

Dependencies

~12–23MB
~328K SLoC