Skip to content
budRich edited this page Jun 13, 2022 · 14 revisions

tutorial

If the command bashbud is executed without any arguments, an error message is printed:

$ bashbud
[ERROR] --template '' not found.
usage: bashbud [--template TEMPLATE] [TARGET_DIR]

available templates in ~/.config/bashbud:
  bud
  CLEANUP
  default
  ERR
  LOG
  mini
  MSG
  readme
  TIMER
  watch

Below is a list of files included in the default template.

~/.config/bashbud/default/
  docs/
    options/
      help
      version
  .gitignore
  config.mak
  main.sh
  Makefile
  options

The command bashbud --template default MyScript
will create a directory called MyScript, and the files from ~/.config/bashbud/default will get copied into this new MyScript/ directory. main.sh will get renamed to MyScript. And last make will execute the Makefile. Resulting in something like this:

MyScript/
  docs/
    options/
      help
      version
  .cache/      (generated by make)
    options/
      help
      version
    getopt
    help_table.md
    help_table.txt
    long_help.md
    options_in_use
    print_help.sh
  .gitignore
  _init.sh     (generated by make)
  _MyScript    (generated by make)
  config.mak
  MyScript     (this is just main.sh renamed)
  Makefile
  options

options file and _init.sh

In the Makefile there is a short embedded AWK script that parses the content of the options file.

$ cat MyScript/options
--help|-h
--version|-v

Peeking at the content of .cache/options_in_use, you will see that it is just one line of the two long-option names separated by spaces. ( help version).

_init.sh contains two functions (__print_version, and __print_help), together with a generated getopt loop, and lastly a call to: main "$@".

If options are added, removed or changed in the options file, it will be reflected in this file.

As a test, we can add the following line to options:
--cool-option1 --option-with-arg ROCKNROLL

If we now execute make in the MyScript directory, __print_help and the getopt loop in _init.sh will look like this:

__print_help()
{
  cat << 'EOB' >&3  
  usage: MyScript [OPTIONS]

    --cool-option1              | short description          
    -v, --version               | print version info and exit
    -h, --help                  | print help and exit        
    --option-with-arg ROCKNROLL | short description          
EOB
}


declare -A _o

options=$(getopt \
  --name "[ERROR]:MyScript" \
  --options "v,h" \
  --longoptions "cool-option1,version,help,option-with-arg:"  -- "$@"
) || exit 98

eval set -- "$options"
unset options

while true; do
  case "$1" in
    --help            | -h ) __print_help && exit ;;
    --version         | -v ) __print_version && exit ;;
    --cool-option1         ) _o[cool-option1]=1 ;;
    --option-with-arg      ) _o[option-with-arg]=$2 ; shift ;;
    -- ) shift ; break ;;
    *  ) break ;;
  esac
  shift
done

Notice that the description for both options is: "short description". This text is taken from the corresponding files in docs/options/. To test, we can change the content of docs/options/cool-option1 to:

if set all will be cool.

Also take note that the getopt loop, where --option-with-arg is expecting an argument, while --cool-option1 is not.

Executing the script like this:

./MyScript --cool-option1 --option-with-arg BUDLABS

Will populate the global _o array like this:

_o[cool-option1]=1
_o[option-with-arg]=BUDLABS

the name of the global array "_o". can be changed by setting the variable OPTIONS_ARRAY_NAME in config.mak

If we change
--options-with-arg ROCKNROLL
to
--options-with-arg|-o (removing the argument, adding short option)
in the options file. And execute make again. We will see the changes in _init.sh:

__print_help()
{
  cat << 'EOB' >&3  
  usage: MyScript [OPTIONS]

    --cool-option1         | if set all will be cool.   
    -v, --version          | print version info and exit
    -h, --help             | print help and exit        
    -o, --options-with-arg | short description          
EOB
}

declare -A _o

options=$(getopt \
  --name "[ERROR]:MyScript" \
  --options "v,h,o" \
  --longoptions "cool-option1,version,help,options-with-arg"  -- "$@"
) || exit 98

eval set -- "$options"
unset options

while true; do
  case "$1" in
    --help             | -h ) __print_help && exit ;;
    --version          | -v ) __print_version && exit ;;
    --cool-option1          ) _o[cool-option1]=1 ;;
    --options-with-arg | -o ) _o[options-with-arg]=1 ;;
    -- ) shift ; break ;;
    *  ) break ;;
  esac
  shift
done

Now lets remove --option-with-arg completely from the option file and execute make again.

__print_help()
{
  cat << 'EOB' >&3  
  usage: MyScript [OPTIONS]

    --cool-option1 | if set all will be cool.   
    -v, --version  | print version info and exit
    -h, --help     | print help and exit        
EOB
}


declare -A _o

options=$(getopt \
  --name "[ERROR]:MyScript" \
  --options "v,h" \
  --longoptions "cool-option1,version,help"  -- "$@"
) || exit 98

eval set -- "$options"
unset options

while true; do
  case "$1" in
    --help         | -h ) __print_help && exit ;;
    --version      | -v ) __print_version && exit ;;
    --cool-option1      ) _o[cool-option1]=1 ;;
    -- ) shift ; break ;;
    *  ) break ;;
  esac
  shift
done

As expected, the references to --option-with-arg is removed from _init.sh. But the files docs/options/option-with-arg and .cache/options/option-with-arg is left. This is intentional, so you can remove/re-apply options without the need to rewrite the documentation.

auto include functions

The last two lines in MyScript are important:

__dir=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")") #bashbud
source "$__dir/_init.sh"                              #bashbud

First line sets the variable __dir to the directory of the script (resolved symlinks). Next line, simply source the _init.sh file we looked at in the section above.

NB

The __dir variable is intended for internal bashbud use only, do never rely on it in your own functions, and do never overwrite or unset it.

remember the last line in _init.sh is just a call to main "$@". The main() is located in MyScript. That's how the script works, you execute, ./MyScript, which in turn source _init.sh and parses the command-line with getopt and lastly call main in MyScript for the actual execution of the script. With the exception if either --version or --help are set, in that case __print_version or __print_help will be called and the script terminated. Or if getopt see incorrect options passed, in which case an error message will get printed.

If we look at _MyScript, we will see that it basically is the two files MyScript and _init.sh neatly concatenated. But the last two lines from MyScript, mentioned above are not present. This is because they end with the comment #bashbud. Lines ending like that will never be included in the concatenated version (_MyScript) of the script.

If the first line of a file ends with '#bashbud' that whole file will be ignored and not included in _MyScript

And as it is now, there is no difference from a users perspective to execute, MyScript vs _MyScript.

Lets create a new directory and a new file:

mkdir func
echo '# tell em!' > func/tellem.sh

The name of the file can be anything, it doesn't matter, but the content must be valid bash. To start We just add a comment to the file.

the name of the directory "func" however, must be "func". But you can change it by setting the variable FUNCS_DIR in config.mak

Now, execute make again, and the content of _init.sh and _MyScript will be slightly different.

In _init.sh just before the getopt loop, the following lines are added:

for ___f in "$__dir/func"/*; do
  . "$___f" ; done ; unset -v ___f

source (.) each file in func/ (currently only our just created tellem.sh).

Looking into _MyScript we will see that in between __print_help() and the getopt loop, the content (# tell em!) of the file.

The source loop in _init.sh uses a wildcard match, so we can add more files to func/ and they will be automatically picked up by the loop without us (or the Makefile) changing _init.sh. _MyScript however, needs to be rebuilt, to reflect the changes.

Lets add a simple function in func/tellem.sh:

#!/bin/bash

tellem() {
  echo "We're cool"
}

And just to demo how multiple function files work, we can create the file func/not_cool.sh:

#!/bin/bash

not_cool() {
  echo "NOT cool"
}

As mentioned, the filenames can be whatever you want but personally I follow the convention of adding the .sh extension and name the files the same as the function they contain. The shbang (#!/bin/bash), has no effect, but it makes it clear for both me and my text-editor that it is a bash file. The shbang in the function files will not be included in _MyScript

Lastly to test the functions we add some logic to main() in MyScript so the file looks like this:

#!/bin/bash

main(){
  
  if ((_o[cool-option1])); then
    tellem
  else
    not_cool
  fi
}

__dir=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")") #bashbud
source "$__dir/_init.sh"                              #bashbud
$ ./MyScript --cool-option1
We're cool
$ ./MyScript
NOT cool

As you can see it all works without the need to "rebuild" anything using make, but to be able to get the same functionality from ./_MyScript we need to make.

why is there two versions of MyScript

There are two main reasons for this:

  1. shellcheck
  2. distribution

[shellcheck] is a very good static code analyzer for shell-scripts, but it is not excellent to analyze shell-scripts that spans multiple files (using source). So by having all files concatenated as we do with _MyScript we can analyze that file with shellcheck to get the correct feedback.

We can test this by changing the the following line in func/not_cool.sh

echo "NOT cool"
to
echo "NOT cool" ${_o[cool-option1]:-not set}

and run: make && shellcheck _MyScript

This will update _MyScript, pass it to shellcheck, and shellcheck will tell us we should add quotes around the variable.

The drawback here, is that will print what line in _MyScript where the error occurred, and not the line in func/not_cool.sh, but it is usually easy to figure out which file the error stems from.

the template "watch" add a script that watches the files in the directory and automatically shellcheck when a change occur.

The second reason for having the concatenated version of the script is distribution. It is easier and more convenient to share and install a single file.

installing scripts

In the default template makefile there are install: and uninstall: targets, however they are declared in config.mak since they are things that quite often needs to be fine tuned for the project.

If you trigger the default make install target, it will try to install the concatenated script (_MyScript) as MyScript in DEST_DIR/PREFIX/bin.

Hardcoded in the GNUmakefile are: install-dev: and uninstall-dev: targets. If you trigger make install-dev a symlink to MyScript(originally main.sh) is installed instead instead.

I prefer to use install-dev and use ~ as the prefix.

$ make PREFIX=~ install-dev
ln -s /home/bud/tmp/MyScript/MyScript /home/bud/bin/MyScript

The install target will also install manpage and LICENSE files if they exist.

generate manpage and documentation

A great side effect of using bashbud to manage a project is that it becomes easy to keep documentation in sync. I think most would agree that using the same table displaying options as --help in a manpage, webpage, README.md, wiki, e.t.c, is nice. But how they are formatted and generated is out of scoop for this wiki and the bashbud utility.

In the default templates config.mak there are manpage: and README.md targets, manpage: requires go-md2man, but they are there as starting points or examples for how to do this. All bash projects at budlabs uses bashbud, and many of them do this stuff differently. Examine the content of the .cache directory some files there are particularly useful to include in documentation.

whatabout git?

As we have seen quite a lot of files are automatically generated. It is rarely desired to include auto generated files in something like a git(1) repository. Partly because it is annoying and difficult to keep track of and commit changes done to those files, and secondly they are not "needed", since they are all generated from existing documents with make. This is why all automatically generated documents that are not in the .cache/ directory, are prefixed with an _. It makes it easy to ignore the files in .gitignore or similar. And since git is so common now, a simple .gitignore is included:

.cache/
**/_*

Related is also, that make clean removes all auto generated files.

extending the Makefile

As you have seen all configuration for make has been done by editing the config.mak (or directly changing variables on the command line make PREFIX=~ install-dev). config.mak is included by the Makefile and you can add your own targets in config.mak (manpage:, README.md). The GNUmakefile will include any files with .mak extension, so you could also create a new file (custom.mak). The takeaway is that, you should hopefully never need to edit the main Makefile, but in some cases you might have to (f.i. if you need to install additional files, like icons or .desktop files), in such case feel free to do that, it is after all, just a Makefile.

Related is how the DEFAULT_GOAL in the makefile is setup:

.PHONY: clean all install-dev uninstall-dev

.DEFAULT_GOAL   := all
all: $(CUSTOM_TARGETS) $(MONOLITH) $(MANPAGE_OUT) $(BASE)

Notice $(CUSTOM_TARGETS). This is a variable that can be set in config.mak to have your own custom targets included with the DEFAULT_GOAL.

Example: if you add the line CUSTOM_TARGETS += manpage, to config.mak, whenever you execute make for that project it will not only generate the script but also the manpage. Without manpage in CUSTOM_TARGETS, you would need to do make manpage separately.

the bash and the AWK

One feature of the bashbud GNUmakefile is that it can generate a file called func/_awklib.sh. This is done if there exist any files in the directory awklib. To demonstrate how and why, lets create awklib/main.awk:

/^#/ {print; comments++}

and awklib/END.awk

END {print FILENAME " contained " comments " comments!"}

Then execute make, to see that func/_awklib.sh is created. This is how it will looks like:

#!/bin/bash

### _awklib() function is automatically generated
### from makefile based on the content of the ./awklib/ directory

_awklib() {
[[ -d $__dir ]] && { cat "$__dir/awklib/"* ; return ;} #bashbud
cat << 'EOAWK'
END {print FILENAME " contained " comments " comments!"}
/^#/ {print; comments++}
EOAWK
}

It gives us the function _awklib which simply will cat the contents of the files in awklib. And we can test it by adding this line to main() in MyScript:
awk -f <(_awklib) "$(readlink -f "${BASH_SOURCE[0]}")"

$ ./MyScrip
NOT cool not set
#!/bin/bash
/home/bud/tmp/MyScript/MyScript contained 1 comments!

The first line of output, is from not_cool(), but the two last lines are from awk, and we can see that it only found 1 comment (the shbang) in the source file.

The above example AWK is of course useless, but this is quite convenient if you have somewhat complex multiline AWK scripts. It keeps both the bash and the AWK cleaner and easier to maintain.

embedded config files

There exist a similar function for config files. Create the following files and directories:

conf/README.md

# this is a sample readme, cool funk!

> just to demonstrate how _createconf() works

conf/dotfiles/settings

# this is a fake settings file, that does nothing
VAR1="or is it?"

Now execute make and func/_createconf.sh should get created, and it looks even messier than _awklib.sh:

#!/bin/bash

### _createconf() function is automatically generated
### from makefile based on the content of the ./conf/ directory

_createconf() {
local trgdir="$1"
mkdir -p "$trgdir" "$trgdir"/dotfiles

if [[ -d $__dir ]]; then #bashbud
cat "$__dir/conf/README.md" > "$trgdir/README.md" #bashbud
else #bashbud
cat << 'EOCONF' > "$trgdir/README.md"
# this is a sample readme, cool funk!

> just to demonstrate how _createconf() works
EOCONF
fi #bashbud

if [[ -d $__dir ]]; then #bashbud
cat "$__dir/conf/dotfiles/settings" > "$trgdir/dotfiles/settings" #bashbud
else #bashbud
cat << 'EOCONF' > "$trgdir/dotfiles/settings"
# this is a fake settings file, that does nothing
VAR1="or is it?"
EOCONF
fi #bashbud
}

This gives you the function _createconf which takes a directory as its single argument. It will create that directory, and all sub-directories needed to mirror the layout defined in conf/ it will proceed creating the files. Note that it does not copy the files instead they are embedded in the script. So still you have a single script file (_MyScript), that will replicate conf/ if you ask it to.

To demonstrate, add the following line as the first one in main():

[[ -d ~/.config/MyScript ]] || _createconf ~/.config/MyScript

customizing templates

It might be desirable to create a personal (or shared) library of functions, that can be reused by other scripts. It is easy to do so with bashbud. All directories in ~/.config/bashbud are templates, up to this point we have only used the default template. But there are more available, and its easy to create your own.

Lets look at the ERR template:

~/.config/bashbud/ERR/
  func/
    ERR.sh

~/.config/bashbud/ERR/func/ERR.sh

#!/bin/bash

set -E
trap '(($? == 98)) && exit 98' ERR

ERX() { >&2 echo  "[ERROR] $*" ; exit 98 ;}
ERR() { >&2 echo  "[WARNING] $*"  ;}
ERM() { >&2 echo  "$*"  ;}

The ERR template contains a single directory, func/, which in turn contains a single file ERR.sh.

If we now execute bashbud --template ERR in our MyScript/ directory, you will see that it copies ERR.sh file from the template into our func/ directory. The easiest way to describe this is that templates, will get merged in to the current tree. The files copied over will not overwrite existing newer files with the same name.

So if we wanted to create our own template we could just create a new directory under ~/.config/bashbud and add whatever file structure we wanted.

So lets try that by creating the following files and directories:

mkdir -p ~/.config/bashbud/budlabs/info \
         ~/.config/bashbud/budlabs/func

~/.config/bashbud/budlabs/info/budlabs.txt

This is just a sample text file

~/.config/bashbud/budlabs/func/budlabs.sh

#!/bin/bash
budlabs(){
  hello "$1" welcome to budlabs!
}

And now: bashbud --template budlabs, will add copies of the files in our budlabs template.

Note that files imported this way are just copies, and you can modify them without worrying it will mess up the template files. And remember the default layout was the one that imported the Makefile, this is why it is no problem to modify it. And since template files doesn't overwrite files *, it will not cause any issues if you by accident try to import, say the default, template again.

bashbud --template TEMPLATE --pull will have the same effect as without --pull , except it will update files in current directory that is older than the ones in the template directory. Adding --force will always overwrite however some files are never overwritten (options, config.mak main.sh)

bashbud --template TEMPLATE --push
will update a template, this is basically the same action as the previous, except it will copy files from the current directory to the template directory in ~/.config/bashbud.

So if we add a comment to the last line of func/bashbud.sh, and line of text to info/bashbud.txt and execute:

bashbud --template budlabs --push while we are in the root of MyScript/, it should update the budlabs template.

It is also possible to create templates, (or add files to an existing template), with the --add option:

$ bashbud --template cool --add func/tellem.sh func/not_cool.sh
bashbud: creating new template: /home/bud/.config/bashbud/cool