├── .Makefile.d-init.mk ├── .gitattributes ├── .gitignore ├── .ruby-version ├── .simplecov ├── .travis.yml ├── Makefile ├── README.md ├── poorman └── test ├── fixture ├── backslash_env │ ├── .env │ └── Procfile ├── backslash_procfile │ └── Procfile ├── basic │ └── Procfile ├── basic_env │ ├── .env │ └── Procfile ├── basic_env_missing_newline │ ├── .env │ └── Procfile ├── basic_env_parameter │ ├── .env │ └── Procfile ├── basic_env_with_commented_assignment │ ├── .env │ └── Procfile ├── basic_env_with_empty_lines │ ├── .env │ └── Procfile ├── basic_env_with_nonstandard_envfile │ ├── Procfile │ └── env ├── basic_missing_newline │ └── Procfile ├── basic_with_comment_in_procfile │ └── Procfile ├── basic_with_early_failure │ ├── Procfile │ ├── fail │ └── happy ├── command_with_space │ ├── Procfile │ └── this command ├── common │ └── tick ├── empty │ └── README.md ├── env_with_distinct_commands │ ├── .env │ ├── Procfile │ ├── command1 │ └── command2 ├── env_with_non_comment_hashes │ ├── .env │ ├── Procfile │ ├── run_then_exit │ └── run_then_wait ├── nested_with_background_processes │ ├── Procfile │ ├── README.md │ ├── poorman │ └── sub │ │ ├── Procfile │ │ ├── Procfile.parent │ │ ├── loop.sh │ │ ├── sub.sh │ │ └── wrapper.sh ├── parameter_in_command │ ├── .env │ └── Procfile └── run_a_command │ ├── .env │ ├── Procfile │ └── test_command ├── poorman.bash ├── poorman.bats ├── poorman_functions.bash ├── test_helper.bash └── utility.bats /.Makefile.d-init.mk: -------------------------------------------------------------------------------- 1 | MAKEFILE_D_INIT_MK := $(abspath $(lastword $(MAKEFILE_LIST))) 2 | 3 | MAKEFILE_D_URL ?= https://github.com/rduplain/Makefile.d.git 4 | MAKEFILE_D_REV ?= v1.4 # Use --ref instead of --tag below if untagged. 5 | 6 | QWERTY_SH_URL ?= https://qwerty.sh 7 | QWERTY_SH ?= curl --proto '=https' --tlsv1.2 -sSf $(QWERTY_SH_URL) | sh -s - 8 | 9 | .Makefile.d/%.mk: .Makefile.d/path.mk 10 | @touch $@ 11 | 12 | .Makefile.d/path.mk: $(MAKEFILE_D_INIT_MK) 13 | $(QWERTY_SH) -f -o .Makefile.d --tag $(MAKEFILE_D_REV) $(MAKEFILE_D_URL) 14 | @touch $@ 15 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # The entire repo is the poorman program with supporting files. 2 | .* linguist-vendored=true 3 | * linguist-vendored=true 4 | poorman linguist-vendored=false 5 | test/* linguist-vendored=true 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage/ 2 | /Gemfile 3 | *.lock 4 | /.Makefile.d/ 5 | /opt/ 6 | /.reqd/ 7 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.0 2 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | require 'coveralls' 3 | 4 | class AllButFilepathFilter < SimpleCov::Filter 5 | # Return true unless given source's filename exactly matches string 6 | # configured when initialized with `AllButFilepathFilter.new('filename')`. 7 | def matches?(source_file) 8 | source_file.project_filename != filter_argument 9 | end 10 | end 11 | 12 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ 13 | Coveralls::SimpleCov::Formatter, 14 | SimpleCov::Formatter::HTMLFormatter, 15 | ]) 16 | 17 | SimpleCov.add_group 'poorman', 'poorman$' 18 | SimpleCov.add_filter AllButFilepathFilter.new('/poorman') 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | matrix: 3 | include: 4 | - os: linux 5 | env: RECIPE=coverage 6 | - os: osx 7 | env: RECIPE=test 8 | script: 9 | - make $RECIPE 10 | cache: 11 | directories: 12 | - .Makefile.d 13 | - ./.reqd/opt 14 | notifications: 15 | email: 16 | on_success: never 17 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: test 2 | 3 | run: 4 | @./poorman start 5 | 6 | test: bats-command 7 | @$(BATS) test/*.bats 8 | 9 | coverage: bashcov-command bats-command 10 | @$(BASHCOV) $(BATS) test/*.bats 11 | 12 | MAKEFILE := $(lastword $(MAKEFILE_LIST)) 13 | 14 | include .Makefile.d-init.mk 15 | include .Makefile.d/bats.mk 16 | include .Makefile.d/ruby.mk 17 | 18 | BASHCOV := $(GEM_HOME)/bin/bashcov 19 | 20 | bashcov-command: $(BASHCOV) 21 | 22 | $(BASHCOV): Gemfile | bundle-command 23 | @$(BUNDLE) install 24 | @touch $@ 25 | 26 | Gemfile: $(MAKEFILE) 27 | @echo "# GENERATED FILE - DO NOT EDIT" > $@ 28 | @echo >> $@ 29 | @echo "source 'https://rubygems.org'" >> $@ 30 | @echo >> $@ 31 | @echo "gem 'bashcov', '~> 1.8'" >> $@ 32 | @echo "gem 'coveralls', '~> 0.8'" >> $@ 33 | 34 | .PHONY: test 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## poorman: a process control system written in shell, for development 2 | 3 | [![Build Status][build]](https://travis-ci.org/rduplain/poorman) 4 | [![Coverage Status][coverage]](https://coveralls.io/r/rduplain/poorman) 5 | 6 | ### Overview 7 | 8 | poorman is a shell port of [foreman](http://ddollar.github.io/foreman/) for 9 | process control using Procfile and .env files, for development on Unix-like 10 | systems. Its only dependency is the bash shell (GNU bash 3.2.0+). It is 11 | designed to run all processes specified in the Procfile in the current 12 | directory, and exit all processes when any such process exits. 13 | 14 | See [Procfile documentation](https://devcenter.heroku.com/articles/procfile). 15 | Either check static Procfile and .env files into version control, or build 16 | these files dynamically with a build system. 17 | 18 | 19 | ### Usage 20 | 21 | To install, put `poorman` in `$PATH`, make it executable (`chmod +x poorman`), 22 | then run `poorman start` in a directory with a Procfile (and optionally a .env 23 | file). 24 | 25 | 26 | ### Running Inside a Project's Environment 27 | 28 | Most of the time, projects need only call `poorman start`. This section 29 | describes how to interact within the environment as defined in .env. 30 | 31 | To run commands inside a project's environment, run (with `poorman` in the 32 | `$PATH`): 33 | 34 | poorman run COMMAND [ARGS...] 35 | 36 | To have a shell source the .env in the same way that poorman does, so that 37 | variables are available on the command line and to executed programs, there are 38 | two options. 39 | 40 | Option 1, start a new interactive shell session: 41 | 42 | poorman run $SHELL 43 | 44 | Option 2, source poorman and call its internal `source_dotenv` utility: 45 | 46 | . poorman source 47 | source_dotenv 48 | 49 | The `source_dotenv` utility takes an optional argument when not using `.env` as 50 | the filepath: 51 | 52 | source_dotenv path/to/env 53 | 54 | Note that poorman is written in bash, and is only tested with bash. For 55 | non-bash shells, verify that option 2 is compatible before proceeding. 56 | 57 | Both options 1 and 2 will setup the shell environment variables for further 58 | interaction and execution. Changes to the .env file are not automatically 59 | detected; either restart the shell (option 1) or call `source_dotenv` again 60 | (option 2). 61 | 62 | 63 | ### Differences Between poorman and foreman 64 | 65 | * poorman is written in shell (bash); foreman is written in Ruby. 66 | * poorman only implements the `start` subcommand and does not support any 67 | option flags; if other subcommands are needed, in particular the `export` 68 | subcommand to write startup configuration files, use foreman directly. 69 | * poorman has no scaling features; it only runs one process per command listed 70 | in the Procfile. 71 | * poorman supports bash's full substitution/expansion feature set in .env. 72 | 73 | 74 | ### Motivation 75 | 76 | The original motivation in porting from foreman: 77 | 78 | 1. Ensure managed processes actually die on exit. 79 | 2. Handle stdout smoothly, without unusual buffering. 80 | 3. Be fast and light on resources on Unix-like systems. 81 | 82 | Further development was motivated by having a lightweight process control 83 | system written in shell, for minimal dependencies with a single script 84 | download. This makes poorman particularly well-suited for local integration 85 | development using classic build tools such as GNU make, where a target such as 86 | `make run` could download and invoke poorman to further invoke all processes 87 | configured for the project. 88 | 89 | 90 | ### References 91 | 92 | * http://ddollar.github.io/foreman/ 93 | * http://blog.daviddollar.org/2011/05/06/introducing-foreman.html 94 | * https://devcenter.heroku.com/articles/procfile 95 | 96 | 97 | ### Contributor Notes 98 | 99 | To test poorman, run `make test`. 100 | 101 | 102 | #### Debugging Poorman 103 | 104 | Any bash program written in `set -e` mode will exit immediately upon failure, 105 | and if the failure case was not predicted by the programmer, then there may be 106 | limited log information before the program exits. In these cases, `bash -x 107 | path/to/poorman start` is useful to see what is happening. If `poorman` is in 108 | `$PATH`, use: 109 | 110 | bash -x poorman start 111 | 112 | 113 | #### Test Coverage Report 114 | 115 | Run: 116 | 117 | make coverage 118 | 119 | Alternative, install the `bashcov` ruby gem, then run `bashcov make test`. View 120 | the report at `coverage/index.html` (tested with bashcov 1.1.0 on ruby 121 | 2.2.0). 122 | 123 | 124 | #### Static Checking in Shell 125 | 126 | [ShellCheck](http://www.shellcheck.net/) is a static checker (i.e. linter) for 127 | shell programs. It is available on Ubuntu/Debian via `sudo apt-get install 128 | shellcheck` since Ubuntu 14.04. 129 | 130 | 131 | [build]: https://travis-ci.org/rduplain/poorman.svg?branch=master 132 | [coverage]: https://coveralls.io/repos/rduplain/poorman/badge.svg?branch=master 133 | -------------------------------------------------------------------------------- /poorman: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # poorman: a process control system written in shell, for development. 3 | # 4 | # Copyright (c) 2013-2021, R. DuPlain 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions are met: 9 | # 10 | # * Redistributions of source code must retain the above copyright notice, 11 | # this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above copyright 13 | # notice, this list of conditions and the following disclaimer in the 14 | # documentation and/or other materials provided with the distribution. 15 | # 16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 19 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 20 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 21 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 22 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 23 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 24 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 25 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | # POSSIBILITY OF SUCH DAMAGE. 27 | 28 | # Exit immediately if a command error or non-zero return occurs. 29 | set -e 30 | set -o pipefail 31 | 32 | # Environment variables during poorman's run time. 33 | declare POORMAN # filepath for this instance of poorman 34 | declare POORMAN_NAME # name of command as listed in Procfile 35 | declare POORMAN_COMMAND # command-line as listed in Procfile 36 | declare POORMAN_PAD # space-filled string to line up stdout logging 37 | declare POORMAN_LOG_PREFIX # pre-computed log prefix 38 | declare POORMAN_LOG_COLOR # ANSI escape sequence for color of current log 39 | 40 | declare POORMAN_PID # PID of start poorman process from `poorman start` 41 | declare POORMAN_LOGGER_PID # PID of logger poorman process in `poorman exec` 42 | declare -a POORMAN_PIDS # array of PIDs of poorman-spawned processes 43 | 44 | # When this is not-empty, poorman will kill only poorman-spawned processes. 45 | # This is specifically useful when poorman's caller is in the same process group. 46 | export POORMAN_SELECTIVE_KILL=${POORMAN_SELECTIVE_KILL:-} 47 | 48 | export PROGRAM=${0##*/} # same as basename 49 | export POORMAN=$0 50 | 51 | # Program count, incremented by 1 on each exec. (Used in rotating colors.) 52 | PROGRAM_COUNT=0 53 | 54 | # Posted: endangered backslashes are protected in mountainous regions. 55 | 56 | # Begin main functions. 57 | 58 | main() { 59 | # User-facing entry point for poorman. 60 | 61 | if [ $# -eq 0 ]; then 62 | # No arguments given. 63 | usage 64 | fi 65 | 66 | local command=$1 67 | shift 68 | 69 | if [ "$command" = "start" ]; then 70 | export POORMAN_PID=$$ 71 | trap_during_setup 72 | main_start "$@" 73 | elif [ "$command" = "exec" ]; then 74 | trap_during_setup 75 | main_exec "$@" 76 | elif [ "$command" = "run" ]; then 77 | trap_during_setup 78 | main_run "$@" 79 | elif [ "$command" = "export" ]; then 80 | main_export "$@" 81 | elif [ "$command" = "check" ]; then 82 | main_check "$@" 83 | elif [ "$command" = "source" ]; then 84 | # Other bash programs can source this file: 85 | # 86 | # . path/to/poorman source 87 | # 88 | # Or if poorman is in the $PATH: 89 | # 90 | # . poorman source 91 | # 92 | # Calling the `source` subcommand prevents normal execution. 93 | # In particular, this is useful in calling poorman functions. 94 | unset POORMAN PROGRAM # These are incorrect when poorman is sourced. 95 | pass 96 | else 97 | echo "error: no such command: $command" >&2 98 | echo >&2 99 | usage 100 | fi 101 | } 102 | 103 | trap_during_setup() { 104 | # Set trap intended for poorman during initialization. 105 | 106 | [[ -n "$POORMAN_SELECTIVE_KILL" ]] || trap 'clear_traps; kill 0' SIGINT SIGTERM EXIT 107 | } 108 | 109 | clear_traps() { 110 | # Call all traps known to be set by poorman. 111 | 112 | trap - SIGINT SIGTERM EXIT 113 | } 114 | 115 | main_start() { 116 | # Load Procfile & execute each command found, after pruning out comments. 117 | 118 | if [ ! -e Procfile ]; then 119 | echo "error: Procfile does not exist" >&2 120 | echo >&2 121 | usage 122 | fi 123 | 124 | source_dotenv 125 | 126 | if [ $# -eq 0 ]; then 127 | main_start_all 128 | else 129 | main_start_one "$@" 130 | fi 131 | } 132 | 133 | main_start_all() { 134 | # Start all processes in Procfile, to be called from main_start. 135 | 136 | # Load the Procfile, parse it, and execute the commands found there. 137 | build_logging_pad 138 | map_lines exec_procfile_line < Procfile 139 | 140 | # Clean up environment in case anything wants to use it. 141 | export POORMAN_NAME=$PROGRAM 142 | unset POORMAN_COMMAND 143 | 144 | # Set trap in a single line to simplify coverage. 145 | [[ -n "$POORMAN_SELECTIVE_KILL" ]] && trap 'clear_traps; kill ${POORMAN_PIDS[@]} >/dev/null 2>&1' SIGINT SIGTERM EXIT || trap 'clear_traps; kill 0' SIGINT SIGTERM EXIT 146 | 147 | wait 148 | } 149 | 150 | main_start_one() { 151 | # Start one process in Procfile, by name, to be called from main_start. 152 | 153 | if [ $# -ne 1 ]; then 154 | echo "error: too many names given: $@" >&2 155 | echo >&2 156 | usage 157 | fi 158 | 159 | local name="$1" 160 | declare command 161 | 162 | map_lines parse_command_for "$name" command < Procfile 163 | 164 | if [ -z "$command" ]; then 165 | echo "error: no command found for '$name'" >&2 166 | return 2 167 | fi 168 | 169 | eval set -- "$command" 170 | exec "$@" 171 | } 172 | 173 | main_exec() { 174 | # Execute given command, logging each line w/metadata prefix. 175 | 176 | # Disable pathname expansion to avoid glob expansion in logs. 177 | set -f 178 | 179 | # Compute the logging prefix to line up stdout among processes. 180 | local pad_length=${#POORMAN_PAD} 181 | local name_length=${#POORMAN_NAME} 182 | let filler_length=pad_length-name_length+1 183 | local log_prefix="$POORMAN_NAME${POORMAN_PAD:0:$filler_length}|" 184 | 185 | # Execute the command, logging each line with timestamp & program name. 186 | export POORMAN_LOG_PREFIX="$log_prefix" 187 | exec "$@" 2>&1 | map_lines log_line & 188 | export POORMAN_LOGGER_PID=$! 189 | 190 | # Set trap in a single line to simplify coverage. 191 | export NEXT_OF_KIN="$POORMAN_PID $POORMAN_LOGGER_PID" 192 | [[ -n "$POORMAN_SELECTIVE_KILL" ]] && trap 'clear_traps; kill $NEXT_OF_KIN >/dev/null 2>&1' SIGINT SIGTERM EXIT || trap 'clear_traps; kill 0' SIGINT SIGTERM EXIT 193 | 194 | wait 195 | } 196 | 197 | main_run() { 198 | # Load .env & execute given command, without any additional log handling. 199 | 200 | source_dotenv 201 | exec "$@" 202 | } 203 | 204 | main_export() { 205 | # Refer to reference implementation of export subcommand. 206 | 207 | echo "$PROGRAM: export not implemented; use foreman export." >&2 208 | return 2 209 | } 210 | 211 | main_check() { 212 | # Refer to reference implementation of check subcommand. 213 | 214 | echo "$PROGRAM: check not implemented; use foreman check." >&2 215 | return 2 216 | } 217 | 218 | add_pid() { 219 | # Add given pid to POORMAN_PIDS, should only be called from main process. 220 | 221 | local pid=$1 222 | shift 223 | 224 | local index=${#POORMAN_PIDS[@]} 225 | POORMAN_PIDS[$index]=$pid 226 | } 227 | 228 | # Begin per-line utilities, called with each line of file or output. 229 | 230 | log_line() { 231 | # Log given line to stdout, prefixed with timestamp & program name. 232 | 233 | colored="$POORMAN_LOG_COLOR$(date +"%H:%M:%S") $POORMAN_LOG_PREFIX\033[0m" 234 | echo -e "$colored" "${*//\\/\\\\}" # <--- Look, mountains! --- 235 | } 236 | 237 | echo_env_export() { 238 | # Print eval-able line, intended for use with .env lines. 239 | 240 | local line="$@" 241 | local line_before_hash=${line%%\#*} 242 | if [[ "$line_before_hash" == *=* ]]; then 243 | # Line has '=' before '#'. Send it along. 244 | echo "export ${line//\\/\\\\}" # <--- Look, mountains! --- 245 | fi 246 | } 247 | 248 | exec_procfile_line() { 249 | # Parse & exec Procfile-style line, intended for use with Procfile lines. 250 | # 251 | # Calls poorman recursively to `exec` into command & support killing group. 252 | 253 | parse_procfile_line POORMAN_NAME POORMAN_COMMAND "$@" 254 | if [ -z "$POORMAN_COMMAND" ]; then 255 | return 256 | fi 257 | eval set -- "$POORMAN_COMMAND" 258 | 259 | export POORMAN_LOG_COLOR=$(pick_color $PROGRAM_COUNT) 260 | let PROGRAM_COUNT=PROGRAM_COUNT+1 261 | $POORMAN exec "$@" 2>/dev/null & 262 | add_pid $! 263 | } 264 | 265 | echo_procfile_name() { 266 | # Parse Procfile-style line, print name of program entry. 267 | 268 | parse_procfile_line name _ "$@" 269 | if [ -z "$name" ]; then 270 | return 271 | fi 272 | echo "$name" 273 | } 274 | 275 | parse_procfile_line() { 276 | # Parse Procfile-style line into arguments given by name: name, command. 277 | # 278 | # `name` is just a string. 279 | # `command` is raw and ready for evaluation by the shell: 280 | # 281 | # parse_procfile_line NAME COMMAND "$procfile_line" 282 | # eval set -- "$COMMAND" 283 | # exec "$@" 284 | # 285 | # Use of eval here allows .env variables to be used in Procfile commands. 286 | 287 | # Understanding poorman internals: 288 | # 289 | # poorman reads each line from the Procfile as a simple, unprocessed string 290 | # (via map_lines), which puts the entire Procfile line into argument `$3` 291 | # when this function is called. 292 | # 293 | # At some point, poorman needs to parse the line from the Procfile exactly 294 | # as the shell would parse it. The best parser for this is the shell itself 295 | # (e.g. `set -- "$COMMAND"`), but caution must be taken in order to avoid 296 | # implicitly processing the string otherwise. Echoing the string will lose 297 | # quoting and escapes. Parsing the string into an argument array here will 298 | # hit limitations in what can be exported, since the name of the command 299 | # variable is taken as an argument. Therefore, this function passes the 300 | # command as a single opaque string, to be parsed via `set` just before 301 | # `exec` is to be called by poorman. 302 | # 303 | # Further, to be complete, poorman must support backslash escapes. The bash 304 | # builtin `read` parses differently from `set`, so is not an option given 305 | # that poorman's design intends to use `set`, as just discussed. Escaping 306 | # backslashes ("mountains") will ensure that `eval set -- "$COMMAND"` has 307 | # the correct level of escaping when parsing, with one exception: 308 | # spaces. '\ ' is special in that its primary intent is to indicate a space 309 | # in an unquoted word, typically a filepath. As such, backslash-escaped 310 | # spaces are intended for the shell parser (i.e. `set`) and are the 311 | # exception in backslash-escaped backslashes (i.e. the "valley" in the 312 | # "mountains"). 313 | 314 | local name_var=$1 315 | local command_var=$2 316 | shift 2 317 | 318 | local line="$@" 319 | 320 | if [[ "$line" =~ ^[[:space:]]*# ]]; then 321 | # Line is comment only. Ensure values are unset for caller inspection. 322 | unset $name_var $command_var 323 | return 324 | fi 325 | 326 | line="${line//\\/\\\\}" # <--- Look, mountains! --- 327 | line="${line//\\\\ /\\ }" # <--- Look, valley! --- 328 | 329 | export $name_var="${line%%:*}" # everything up to first ':' 330 | export $command_var="${line#*: }" # everything after first ':' 331 | } 332 | 333 | parse_command_for() { 334 | # Given a name and a Procfile-style line, print command if name matches. 335 | 336 | local name_var=$1 337 | local command_var=$2 338 | shift 2 339 | 340 | declare _parse_command_for_name _parse_command_for_command 341 | parse_procfile_line _parse_command_for_name _parse_command_for_command "$@" 342 | 343 | if [ "$_parse_command_for_name" = "$name_var" ]; then 344 | export $command_var="$_parse_command_for_command" 345 | fi 346 | } 347 | 348 | # Begin map utility to process lines in text using manageable functions. 349 | 350 | map_lines() { 351 | # Execute given command-line for each line in stdin. 352 | 353 | # Understanding read & internal field separator (IFS) in bash: 354 | # 355 | # The `read` builtin reads one line of stdin and assigns words to given 356 | # names. Setting the internal field separator (IFS) to an empty value 357 | # prevents `read` from trimming whitespace from the line. Using `read -r` 358 | # considers a backslash as part of the input line and not an escape. Per 359 | # POSIX, `read` exits non-zero if an end-of-file occurs, which would happen 360 | # if input ends with a line that does not end with a newline, in which case 361 | # bash still assigns the value to the given name when exiting non-zero. 362 | 363 | local line_command="$@" 364 | shift 365 | 366 | if [ -z "$line_command" ]; then 367 | echo 'error: no command given to map_lines' >&2 368 | echo 'usage: map_lines COMMAND' >&2 369 | return 2 370 | fi 371 | 372 | while IFS= read -r line || [ -n "$line" ]; do 373 | $line_command "$line" 374 | local result=$? 375 | if [ $result -ne 0 ]; then 376 | # Ensure errors do not get swallowed in this loop. 377 | return $result 378 | fi 379 | done 380 | } 381 | 382 | # Begin top-level program utilities. 383 | 384 | source_dotenv() { 385 | # Source the .env file in the current working directory, if it exists. 386 | 387 | local dotenv="${1:-.env}" 388 | 389 | if [ -e "$dotenv" ]; then 390 | eval "$(map_lines echo_env_export < "$dotenv")" 391 | fi 392 | } 393 | 394 | build_logging_pad() { 395 | # Inspect all Procfile names & set POORMAN_PAD accordingly. 396 | 397 | unset POORMAN_PAD 398 | 399 | # Find the maximum length name in the Procfile. 400 | local length=0 401 | for name in $(map_lines echo_procfile_name < Procfile); do 402 | if [ ${#name} -gt $length ]; then 403 | length=${#name} 404 | fi 405 | done 406 | 407 | # Space-fill the pad using that length. 408 | POORMAN_PAD="" 409 | while [ ${#POORMAN_PAD} -lt $length ]; do 410 | POORMAN_PAD="$POORMAN_PAD " 411 | done 412 | export POORMAN_PAD 413 | } 414 | 415 | # ANSI color codes to use when logging to the terminal, in rotation. 416 | # cyan yellow green magenta red blue 417 | COLORS=(36 33 32 35 31 34) 418 | 419 | pick_color() { 420 | # Pick a color from a preset given an integer, echo ANSI escape sequence. 421 | 422 | if [ $# -eq 0 ]; then 423 | return 424 | fi 425 | 426 | local number=$1 427 | shift 428 | 429 | let number_of_colors=${#COLORS[@]} 430 | 431 | let index=$number%$number_of_colors 432 | echo '\033['${COLORS[$index]}m 433 | } 434 | 435 | pass() { 436 | # No operation. 437 | 438 | : 439 | } 440 | 441 | usage() { 442 | # Print poorman program usage to stderr & return 2. 443 | 444 | clear_traps 445 | 446 | echo "usage: $PROGRAM start [PROCESS] # Start processes." >&2 447 | echo "usage: $PROGRAM run COMMAND [ARGS...] # Run arbitrary command." >&2 448 | echo >&2 449 | echo "$PROGRAM is a shell port of foreman." >&2 450 | echo "It reads Procfile & .env files in the current working directory." >&2 451 | return 2 452 | } 453 | 454 | main "$@" 455 | -------------------------------------------------------------------------------- /test/fixture/backslash_env/.env: -------------------------------------------------------------------------------- 1 | BS=\n 2 | -------------------------------------------------------------------------------- /test/fixture/backslash_env/Procfile: -------------------------------------------------------------------------------- 1 | test: echo $BS 2 | -------------------------------------------------------------------------------- /test/fixture/backslash_procfile/Procfile: -------------------------------------------------------------------------------- 1 | test: echo \n 2 | -------------------------------------------------------------------------------- /test/fixture/basic/Procfile: -------------------------------------------------------------------------------- 1 | one: ../common/tick 2 | two: ../common/tick 3 | three: ../common/tick --exit 4 | -------------------------------------------------------------------------------- /test/fixture/basic_env/.env: -------------------------------------------------------------------------------- 1 | DOT=- 2 | -------------------------------------------------------------------------------- /test/fixture/basic_env/Procfile: -------------------------------------------------------------------------------- 1 | ../basic/Procfile -------------------------------------------------------------------------------- /test/fixture/basic_env_missing_newline/.env: -------------------------------------------------------------------------------- 1 | DOT=+ -------------------------------------------------------------------------------- /test/fixture/basic_env_missing_newline/Procfile: -------------------------------------------------------------------------------- 1 | ../basic/Procfile -------------------------------------------------------------------------------- /test/fixture/basic_env_parameter/.env: -------------------------------------------------------------------------------- 1 | __X=x 2 | DOT=$__X 3 | -------------------------------------------------------------------------------- /test/fixture/basic_env_parameter/Procfile: -------------------------------------------------------------------------------- 1 | ../basic/Procfile -------------------------------------------------------------------------------- /test/fixture/basic_env_with_commented_assignment/.env: -------------------------------------------------------------------------------- 1 | DOT=v 2 | #DOT=u 3 | # DOT=t 4 | -------------------------------------------------------------------------------- /test/fixture/basic_env_with_commented_assignment/Procfile: -------------------------------------------------------------------------------- 1 | ../basic/Procfile -------------------------------------------------------------------------------- /test/fixture/basic_env_with_empty_lines/.env: -------------------------------------------------------------------------------- 1 | # Comment 1. 2 | 3 | DOT=. 4 | 5 | # Comment 2. 6 | 7 | DOT=- 8 | 9 | -------------------------------------------------------------------------------- /test/fixture/basic_env_with_empty_lines/Procfile: -------------------------------------------------------------------------------- 1 | 2 | one: ../common/tick 3 | 4 | two: ../common/tick 5 | 6 | three: ../common/tick --exit 7 | 8 | -------------------------------------------------------------------------------- /test/fixture/basic_env_with_nonstandard_envfile/Procfile: -------------------------------------------------------------------------------- 1 | ../basic/Procfile -------------------------------------------------------------------------------- /test/fixture/basic_env_with_nonstandard_envfile/env: -------------------------------------------------------------------------------- 1 | DOT=- 2 | -------------------------------------------------------------------------------- /test/fixture/basic_missing_newline/Procfile: -------------------------------------------------------------------------------- 1 | one: ../common/tick 2 | two: ../common/tick 3 | three: ../common/tick --exit -------------------------------------------------------------------------------- /test/fixture/basic_with_comment_in_procfile/Procfile: -------------------------------------------------------------------------------- 1 | # This is a comment. 2 | one: ../common/tick 3 | two: ../common/tick 4 | three: ../common/tick --exit 5 | -------------------------------------------------------------------------------- /test/fixture/basic_with_early_failure/Procfile: -------------------------------------------------------------------------------- 1 | emo: ./happy 2 | menace: ./fail 3 | -------------------------------------------------------------------------------- /test/fixture/basic_with_early_failure/fail: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Fail. 3 | 4 | sleep 1 5 | echo "I fail you." >&2 6 | exit 1 7 | -------------------------------------------------------------------------------- /test/fixture/basic_with_early_failure/happy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Harmless program with predictably delayed emotions. 3 | 4 | echo I am happy. 5 | sleep 3 6 | echo I am sad. 7 | sleep 3 8 | echo I am still sad. 9 | -------------------------------------------------------------------------------- /test/fixture/command_with_space/Procfile: -------------------------------------------------------------------------------- 1 | command1: ./this\ command foo bar baz 2 | command2: './this command' foo bar baz 3 | -------------------------------------------------------------------------------- /test/fixture/command_with_space/this command: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Echo arguments. 3 | 4 | echo "$@" 5 | sleep 1 6 | -------------------------------------------------------------------------------- /test/fixture/common/tick: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # tick: echo . .. ..., wait indefinitely unless --exit given; change . with $DOT. 3 | 4 | dot=${DOT:-.} 5 | 6 | echo $dot 7 | echo $dot$dot 8 | echo $dot$dot$dot 9 | 10 | if [ "$1" = "--exit" ]; then 11 | sleep 1 12 | exit 13 | else 14 | sleep 2 15 | fi 16 | -------------------------------------------------------------------------------- /test/fixture/empty/README.md: -------------------------------------------------------------------------------- 1 | Fixture directory with only a README, for testing. 2 | -------------------------------------------------------------------------------- /test/fixture/env_with_distinct_commands/.env: -------------------------------------------------------------------------------- 1 | NAME=world 2 | -------------------------------------------------------------------------------- /test/fixture/env_with_distinct_commands/Procfile: -------------------------------------------------------------------------------- 1 | command-one: ./command1 2 | command-two: ./command2 3 | -------------------------------------------------------------------------------- /test/fixture/env_with_distinct_commands/command1: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Echo something personal. 3 | 4 | echo "Hello, $NAME!" 5 | echo "Hello, $NAME!" 6 | echo "Hello, $NAME!" 7 | -------------------------------------------------------------------------------- /test/fixture/env_with_distinct_commands/command2: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Be uninteresting. 3 | 4 | echo "You have the wrong command." 5 | sleep 2 6 | -------------------------------------------------------------------------------- /test/fixture/env_with_non_comment_hashes/.env: -------------------------------------------------------------------------------- 1 | EMAIL=info@example.com 2 | CHANNEL=#cville 3 | EMAIL_DOMAIN=${EMAIL#*@} # Strip everything up to and including '@'. 4 | QUOTED="Why is this # here?" 5 | -------------------------------------------------------------------------------- /test/fixture/env_with_non_comment_hashes/Procfile: -------------------------------------------------------------------------------- 1 | channel: ./run_then_wait echo $CHANNEL 2 | email-domain: ./run_then_wait echo $EMAIL_DOMAIN 3 | quoted: ./run_then_exit echo $QUOTED 4 | -------------------------------------------------------------------------------- /test/fixture/env_with_non_comment_hashes/run_then_exit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Sleep for a bit, run given argument, then exit. 3 | 4 | sleep 1 5 | env "$@" 6 | -------------------------------------------------------------------------------- /test/fixture/env_with_non_comment_hashes/run_then_wait: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Run given command, then wait indefinitely. 3 | 4 | set -e 5 | 6 | if [ $# -gt 0 ]; then 7 | env "$@" 8 | fi 9 | 10 | sleep 2 11 | -------------------------------------------------------------------------------- /test/fixture/nested_with_background_processes/Procfile: -------------------------------------------------------------------------------- 1 | sub/Procfile.parent -------------------------------------------------------------------------------- /test/fixture/nested_with_background_processes/README.md: -------------------------------------------------------------------------------- 1 | This fixture tests poorman's `kill 0` implementation and consistent use of 2 | poorman environment variables in case poorman calls poorman. 3 | 4 | Given this complexity, and that the scripts enclosed do not trap to kill 5 | backgrounded processes, this fixture is not able to be run with 6 | `POORMAN_SELECTIVE_KILL` set. Instead, this fixture provides for developer 7 | inspection. To test, change directories to this fixture and run: 8 | 9 | ./poorman start 10 | ^C # After a few moments. 11 | ps # See that no stray processes are running. 12 | -------------------------------------------------------------------------------- /test/fixture/nested_with_background_processes/poorman: -------------------------------------------------------------------------------- 1 | ../../../poorman -------------------------------------------------------------------------------- /test/fixture/nested_with_background_processes/sub/Procfile: -------------------------------------------------------------------------------- 1 | one: ./wrapper.sh 2 | two: ./wrapper.sh 3 | three: ./wrapper.sh 4 | -------------------------------------------------------------------------------- /test/fixture/nested_with_background_processes/sub/Procfile.parent: -------------------------------------------------------------------------------- 1 | a: ./sub/sub.sh 2 | b: ./sub/sub.sh 3 | c: ./sub/sub.sh 4 | -------------------------------------------------------------------------------- /test/fixture/nested_with_background_processes/sub/loop.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | while :; do ps -jp $$; sleep 2; done 4 | -------------------------------------------------------------------------------- /test/fixture/nested_with_background_processes/sub/sub.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd sub 4 | ../poorman start 5 | -------------------------------------------------------------------------------- /test/fixture/nested_with_background_processes/sub/wrapper.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ./loop.sh & 4 | sleep 10 5 | wait 6 | -------------------------------------------------------------------------------- /test/fixture/parameter_in_command/.env: -------------------------------------------------------------------------------- 1 | __X=x 2 | __Y=y 3 | __Z=z 4 | DOT=$__X 5 | -------------------------------------------------------------------------------- /test/fixture/parameter_in_command/Procfile: -------------------------------------------------------------------------------- 1 | one: ../common/tick 2 | two: env DOT=y ../common/tick --exit 3 | three: env DOT=$__Z ../common/tick --exit 4 | -------------------------------------------------------------------------------- /test/fixture/run_a_command/.env: -------------------------------------------------------------------------------- 1 | FACT_FOR_USE_WITH_TEST_COMMAND_123456="This value is from a .env file." 2 | -------------------------------------------------------------------------------- /test/fixture/run_a_command/Procfile: -------------------------------------------------------------------------------- 1 | ../basic/Procfile -------------------------------------------------------------------------------- /test/fixture/run_a_command/test_command: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Echo a fact from .env, for testing. 3 | 4 | echo $FACT_FOR_USE_WITH_TEST_COMMAND_123456 5 | -------------------------------------------------------------------------------- /test/poorman.bash: -------------------------------------------------------------------------------- 1 | # Load poorman_path function from poorman_functions.bash, and run it. 2 | test_dir="$( cd -P "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 3 | . $test_dir/poorman_functions.bash source 4 | _POORMAN_PATH=`poorman_path` 5 | 6 | run_poorman() { 7 | # Run poorman with bats `run` command in poorman selective kill mode. 8 | 9 | export POORMAN_SELECTIVE_KILL=true 10 | run $_POORMAN_PATH "$@" 11 | } 12 | 13 | filter_control_sequences() { 14 | # Lifted from bats project, filter terminal control sequences. 15 | 16 | "$@" | sed $'s,\x1b\\[[0-9;]*[a-zA-Z],,g' 17 | } 18 | 19 | run_poorman_filtered() { 20 | # Run poorman, filtering terminal control sequences. 21 | 22 | export POORMAN_SELECTIVE_KILL=true 23 | run filter_control_sequences $_POORMAN_PATH "$@" 24 | } 25 | 26 | cut_timestamps() { 27 | # Cut poorman timestamps, by cutting first field of each line. 28 | 29 | "$@" | cut -f 2- -d " " 30 | } 31 | 32 | run_poorman_filtered_without_timestamps() { 33 | # Run poorman, filtering terminal control sequences and cutting 34 | 35 | export POORMAN_SELECTIVE_KILL=true 36 | run filter_control_sequences cut_timestamps $_POORMAN_PATH "$@" 37 | } 38 | -------------------------------------------------------------------------------- /test/poorman.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | load test_helper 4 | load poorman 5 | 6 | USAGE_LINE="usage: poorman start [PROCESS] # Start processes." 7 | 8 | @test "poorman: invocation without arguments prints usage" { 9 | run_poorman 10 | assert_equal "usage line" "$USAGE_LINE" "${lines[0]}" 11 | assert_equal "exit code" 2 $status 12 | } 13 | 14 | @test "poorman: invocation with invalid subcommand prints usage" { 15 | run_poorman fake 16 | assert_equal "error line" "error: no such command: fake" "${lines[0]}" 17 | assert_equal "usage line" "$USAGE_LINE" "${lines[1]}" 18 | assert_equal "exit code" 2 $status 19 | } 20 | 21 | @test "poorman: invocation without Procfile prints usage" { 22 | fixture empty 23 | assert_does_not_exist "local Procfile does not exist when testing" Procfile 24 | run_poorman start 25 | assert_equal "error line" "error: Procfile does not exist" "${lines[0]}" 26 | assert_equal "usage line" "$USAGE_LINE" "${lines[1]}" 27 | assert_equal "exit code" 2 $status 28 | } 29 | 30 | @test "poorman: basic Procfile" { 31 | fixture basic 32 | run_poorman_filtered_without_timestamps start 33 | sort_lines 34 | assert_equal "line 1" "one | ." "${lines[0]}" 35 | assert_equal "line 2" "one | .." "${lines[1]}" 36 | assert_equal "line 3" "one | ..." "${lines[2]}" 37 | assert_equal "line 4" "three | ." "${lines[3]}" 38 | assert_equal "line 5" "three | .." "${lines[4]}" 39 | assert_equal "line 6" "three | ..." "${lines[5]}" 40 | assert_equal "line 7" "two | ." "${lines[6]}" 41 | assert_equal "line 8" "two | .." "${lines[7]}" 42 | assert_equal "line 9" "two | ..." "${lines[8]}" 43 | assert_equal "number of lines" 9 ${#lines[@]} 44 | } 45 | 46 | @test "poorman: basic Procfile with .env" { 47 | fixture basic_env 48 | run_poorman_filtered_without_timestamps start 49 | sort_lines 50 | assert_equal "line 1" "one | -" "${lines[0]}" 51 | assert_equal "line 2" "one | --" "${lines[1]}" 52 | assert_equal "line 3" "one | ---" "${lines[2]}" 53 | assert_equal "line 4" "three | -" "${lines[3]}" 54 | assert_equal "line 5" "three | --" "${lines[4]}" 55 | assert_equal "line 6" "three | ---" "${lines[5]}" 56 | assert_equal "line 7" "two | -" "${lines[6]}" 57 | assert_equal "line 8" "two | --" "${lines[7]}" 58 | assert_equal "line 9" "two | ---" "${lines[8]}" 59 | assert_equal "number of lines" 9 ${#lines[@]} 60 | } 61 | 62 | @test "poorman: basic Procfile with .env containing a shell parameter" { 63 | fixture basic_env_parameter 64 | run_poorman_filtered_without_timestamps start 65 | sort_lines 66 | assert_equal "line 1" "one | x" "${lines[0]}" 67 | assert_equal "line 2" "one | xx" "${lines[1]}" 68 | assert_equal "line 3" "one | xxx" "${lines[2]}" 69 | assert_equal "line 4" "three | x" "${lines[3]}" 70 | assert_equal "line 5" "three | xx" "${lines[4]}" 71 | assert_equal "line 6" "three | xxx" "${lines[5]}" 72 | assert_equal "line 7" "two | x" "${lines[6]}" 73 | assert_equal "line 8" "two | xx" "${lines[7]}" 74 | assert_equal "line 9" "two | xxx" "${lines[8]}" 75 | assert_equal "number of lines" 9 ${#lines[@]} 76 | } 77 | 78 | @test "poorman: basic Procfile with .env, both with additional empty lines" { 79 | fixture basic_env_with_empty_lines 80 | run_poorman_filtered_without_timestamps start 81 | sort_lines 82 | assert_equal "line 1" "one | -" "${lines[0]}" 83 | assert_equal "line 2" "one | --" "${lines[1]}" 84 | assert_equal "line 3" "one | ---" "${lines[2]}" 85 | assert_equal "line 4" "three | -" "${lines[3]}" 86 | assert_equal "line 5" "three | --" "${lines[4]}" 87 | assert_equal "line 6" "three | ---" "${lines[5]}" 88 | assert_equal "line 7" "two | -" "${lines[6]}" 89 | assert_equal "line 8" "two | --" "${lines[7]}" 90 | assert_equal "line 9" "two | ---" "${lines[8]}" 91 | assert_equal "number of lines" 9 ${#lines[@]} 92 | } 93 | 94 | @test "poorman: basic Procfile with .env with '=' in a comment" { 95 | fixture basic_env_with_commented_assignment 96 | run_poorman_filtered_without_timestamps start 97 | sort_lines 98 | assert_equal "line 1" "one | v" "${lines[0]}" 99 | assert_equal "line 2" "one | vv" "${lines[1]}" 100 | assert_equal "line 3" "one | vvv" "${lines[2]}" 101 | assert_equal "line 4" "three | v" "${lines[3]}" 102 | assert_equal "line 5" "three | vv" "${lines[4]}" 103 | assert_equal "line 6" "three | vvv" "${lines[5]}" 104 | assert_equal "line 7" "two | v" "${lines[6]}" 105 | assert_equal "line 8" "two | vv" "${lines[7]}" 106 | assert_equal "line 9" "two | vvv" "${lines[8]}" 107 | assert_equal "number of lines" 9 ${#lines[@]} 108 | } 109 | 110 | @test "poorman: basic Procfile with a comment" { 111 | fixture basic_with_comment_in_procfile 112 | run_poorman_filtered_without_timestamps start 113 | sort_lines 114 | assert_equal "line 1" "one | ." "${lines[0]}" 115 | assert_equal "line 2" "one | .." "${lines[1]}" 116 | assert_equal "line 3" "one | ..." "${lines[2]}" 117 | assert_equal "line 4" "three | ." "${lines[3]}" 118 | assert_equal "line 5" "three | .." "${lines[4]}" 119 | assert_equal "line 6" "three | ..." "${lines[5]}" 120 | assert_equal "line 7" "two | ." "${lines[6]}" 121 | assert_equal "line 8" "two | .." "${lines[7]}" 122 | assert_equal "line 9" "two | ..." "${lines[8]}" 123 | assert_equal "number of lines" 9 ${#lines[@]} 124 | } 125 | 126 | @test "poorman: basic Procfile missing newline" { 127 | fixture basic_missing_newline 128 | run_poorman_filtered_without_timestamps start 129 | sort_lines 130 | assert_equal "line 1" "one | ." "${lines[0]}" 131 | assert_equal "line 2" "one | .." "${lines[1]}" 132 | assert_equal "line 3" "one | ..." "${lines[2]}" 133 | assert_equal "line 4" "three | ." "${lines[3]}" 134 | assert_equal "line 5" "three | .." "${lines[4]}" 135 | assert_equal "line 6" "three | ..." "${lines[5]}" 136 | assert_equal "line 7" "two | ." "${lines[6]}" 137 | assert_equal "line 8" "two | .." "${lines[7]}" 138 | assert_equal "line 9" "two | ..." "${lines[8]}" 139 | assert_equal "number of lines" 9 ${#lines[@]} 140 | } 141 | 142 | @test "poorman: basic Procfile with .env missing newline" { 143 | fixture basic_env_missing_newline 144 | run_poorman_filtered_without_timestamps start 145 | sort_lines 146 | assert_equal "line 1" "one | +" "${lines[0]}" 147 | assert_equal "line 2" "one | ++" "${lines[1]}" 148 | assert_equal "line 3" "one | +++" "${lines[2]}" 149 | assert_equal "line 4" "three | +" "${lines[3]}" 150 | assert_equal "line 5" "three | ++" "${lines[4]}" 151 | assert_equal "line 6" "three | +++" "${lines[5]}" 152 | assert_equal "line 7" "two | +" "${lines[6]}" 153 | assert_equal "line 8" "two | ++" "${lines[7]}" 154 | assert_equal "line 9" "two | +++" "${lines[8]}" 155 | assert_equal "number of lines" 9 ${#lines[@]} 156 | } 157 | 158 | @test "poorman: basic Procfile with early failure of one process" { 159 | fixture basic_with_early_failure 160 | run_poorman_filtered_without_timestamps start 161 | sort_lines 162 | assert_equal "line 1" "emo | I am happy." "${lines[0]}" 163 | assert_equal "line 2" "menace | I fail you." "${lines[1]}" 164 | assert_equal "number of lines" 2 ${#lines[@]} 165 | } 166 | 167 | @test "poorman: Procfile with a backslash" { 168 | fixture backslash_procfile 169 | run_poorman_filtered_without_timestamps start 170 | assert_equal "line 1" "test | \\n" "${lines[0]}" 171 | assert_equal "number of lines" 1 ${#lines[@]} 172 | } 173 | 174 | @test "poorman: .env with a backslash" { 175 | fixture backslash_env 176 | run_poorman_filtered_without_timestamps start 177 | assert_equal "line 1" "test | \\n" "${lines[0]}" 178 | assert_equal "number of lines" 1 ${#lines[@]} 179 | } 180 | 181 | @test "poorman: .env with non-comment hashes" { 182 | fixture env_with_non_comment_hashes 183 | run_poorman_filtered_without_timestamps start 184 | sort_lines 185 | assert_equal "line 1" "channel | #cville" "${lines[0]}" 186 | assert_equal "line 2" "email-domain | example.com" "${lines[1]}" 187 | assert_equal "line 3" "quoted | Why is this # here?" "${lines[2]}" 188 | assert_equal "number of lines" 3 ${#lines[@]} 189 | } 190 | 191 | @test "poorman: Procfile with .env, start just one command" { 192 | fixture env_with_distinct_commands 193 | run_poorman start command-one 194 | assert_equal "line 1" "Hello, world!" "${lines[0]}" 195 | assert_equal "line 2" "Hello, world!" "${lines[1]}" 196 | assert_equal "line 3" "Hello, world!" "${lines[2]}" 197 | assert_equal "number of lines" 3 ${#lines[@]} 198 | } 199 | 200 | @test "poorman: start two commands, show error on number of commands" { 201 | fixture env_with_distinct_commands 202 | run_poorman start x y 203 | assert_equal "error line" "error: too many names given: x y" "${lines[0]}" 204 | assert_equal "exit code" 2 $status 205 | } 206 | 207 | @test "poorman: start one command, display an error when not found" { 208 | fixture env_with_distinct_commands 209 | run_poorman start xyz 210 | assert_equal "error line" "error: no command found for 'xyz'" "${lines[0]}" 211 | assert_equal "exit code" 2 $status 212 | } 213 | 214 | @test "poorman: Procfile with commands containing shell parameters" { 215 | fixture parameter_in_command 216 | run_poorman_filtered_without_timestamps start 217 | sort_lines 218 | assert_equal "line 1" "one | x" "${lines[0]}" 219 | assert_equal "line 2" "one | xx" "${lines[1]}" 220 | assert_equal "line 3" "one | xxx" "${lines[2]}" 221 | assert_equal "line 4" "three | z" "${lines[3]}" 222 | assert_equal "line 5" "three | zz" "${lines[4]}" 223 | assert_equal "line 6" "three | zzz" "${lines[5]}" 224 | assert_equal "line 7" "two | y" "${lines[6]}" 225 | assert_equal "line 8" "two | yy" "${lines[7]}" 226 | assert_equal "line 9" "two | yyy" "${lines[8]}" 227 | assert_equal "number of lines" 9 ${#lines[@]} 228 | } 229 | 230 | @test "poorman: start one process containing a shell parameter" { 231 | fixture parameter_in_command 232 | run_poorman start two 233 | assert_equal "line 1" "y" "${lines[0]}" 234 | assert_equal "line 2" "yy" "${lines[1]}" 235 | assert_equal "line 3" "yyy" "${lines[2]}" 236 | assert_equal "number of lines" 3 ${#lines[@]} 237 | } 238 | 239 | @test "poorman: start one process containing another shell parameter" { 240 | fixture parameter_in_command 241 | run_poorman start three 242 | assert_equal "line 1" "z" "${lines[0]}" 243 | assert_equal "line 2" "zz" "${lines[1]}" 244 | assert_equal "line 3" "zzz" "${lines[2]}" 245 | assert_equal "number of lines" 3 ${#lines[@]} 246 | } 247 | 248 | @test "poorman: Procfile with command containing a space" { 249 | fixture command_with_space 250 | run_poorman_filtered_without_timestamps start 251 | sort_lines 252 | assert_equal "line 1" "command1 | foo bar baz" "${lines[0]}" 253 | assert_equal "line 2" "command2 | foo bar baz" "${lines[1]}" 254 | assert_equal "number of lines" 2 ${#lines[@]} 255 | } 256 | 257 | @test "poorman: start one process containing a space" { 258 | fixture command_with_space 259 | run_poorman start command1 260 | assert_equal "line 1" "foo bar baz" "${lines[0]}" 261 | assert_equal "number of lines" 1 ${#lines[@]} 262 | } 263 | 264 | @test "poorman: run an arbitrary command" { 265 | fixture run_a_command 266 | run_poorman run ./test_command 267 | assert_equal "output" "This value is from a .env file." "${lines[0]}" 268 | assert_equal "exit code" 0 $status 269 | } 270 | 271 | @test "poorman: run a command with a space using backslash" { 272 | fixture command_with_space 273 | run_poorman run ./this\ command foo bar baz 274 | assert_equal "output" "foo bar baz" "${lines[0]}" 275 | assert_equal "exit code" 0 $status 276 | } 277 | 278 | @test "poorman: run a command with a space using quotes" { 279 | fixture command_with_space 280 | run_poorman run './this command' foo bar baz 281 | assert_equal "output" "foo bar baz" "${lines[0]}" 282 | assert_equal "exit code" 0 $status 283 | } 284 | 285 | @test "poorman: export refers to reference implementation" { 286 | fixture run_a_command 287 | run_poorman export 288 | export_error="poorman: export not implemented; use foreman export." 289 | assert_equal "output" "$export_error" "${lines[0]}" 290 | assert_equal "exit code" 2 $status 291 | } 292 | 293 | @test "poorman: check refers to reference implementation" { 294 | fixture run_a_command 295 | run_poorman check 296 | check_error="poorman: check not implemented; use foreman check." 297 | assert_equal "output" "$check_error" "${lines[0]}" 298 | assert_equal "exit code" 2 $status 299 | } 300 | -------------------------------------------------------------------------------- /test/poorman_functions.bash: -------------------------------------------------------------------------------- 1 | poorman_path() { 2 | # Print path to poorman script. 3 | 4 | local dir="$( cd -P "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )" 5 | local filepath=$dir/poorman 6 | if [ ! -e $filepath ]; then 7 | echo "error: poorman not found: $filepath" >&2 8 | return 1 9 | fi 10 | echo $filepath 11 | } 12 | 13 | if [ "$1" = "source" ]; then 14 | # Source this file, to get utility function. 15 | :; 16 | else 17 | # Load this file, source poorman functions as a test helper. 18 | . `poorman_path` source 19 | fi 20 | -------------------------------------------------------------------------------- /test/test_helper.bash: -------------------------------------------------------------------------------- 1 | fixture() { 2 | export FIXTURE_ROOT="$BATS_TEST_DIRNAME/fixture/$1" 3 | cd $FIXTURE_ROOT 4 | } 5 | 6 | sort_lines() { 7 | OLDIFS="$IFS" 8 | IFS=$'\n' lines=($(sort -n <<<"${lines[*]}")) 9 | IFS="$OLDIFS" 10 | } 11 | 12 | assert_equal() { 13 | local test=$1 14 | local expected=$2 15 | local actual=$3 16 | 17 | if [ $# -ne 3 ]; then 18 | # Incorrect usage of this function. 19 | test="number of arguments" 20 | expected=3 21 | actual=$# 22 | return 1 23 | fi 24 | 25 | if [ "$expected" != "$actual" ]; then 26 | echo "test: $test" >&2 27 | echo "expected: $expected" >&2 28 | echo "actual: $actual" >&2 29 | return 1 30 | fi 31 | } 32 | 33 | assert_does_not_exist() { 34 | assert_equal "number of arguments" "2" "$#" 35 | local test=$1 36 | local file=$2 37 | if [ -e $file ]; then 38 | echo "test: $test" >&2 39 | echo "failure: $file exists" >&2 40 | return 1 41 | fi 42 | } 43 | 44 | assert_not_equal() { 45 | assert_equal "number of arguments" "3" "$#" 46 | local test=$1 47 | local expected=$2 48 | local actual=$3 49 | if [ "$expected" == "$actual" ]; then 50 | echo "test: $test" >&2 51 | echo "unexpected: $actual" >&2 52 | return 1 53 | fi 54 | } 55 | 56 | assert_is_number() { 57 | assert_equal "number of arguments" "2" "$#" 58 | local test=$1 59 | local value=$2 60 | case $value in 61 | ''|*[!0-9]*) 62 | echo "test: $test" >&2 63 | echo "not a number: $value" >&2 64 | return 1 65 | ;; 66 | *) 67 | ;; 68 | esac 69 | } 70 | -------------------------------------------------------------------------------- /test/utility.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | load poorman_functions 4 | load test_helper 5 | 6 | @test "pass: smoke test" { 7 | run pass 8 | assert_equal "exit code" "0" "$status" 9 | } 10 | 11 | print_three_lines() { 12 | echo one 13 | echo two 14 | echo three 15 | } 16 | 17 | print_two_lines_then_fail() { 18 | echo one 19 | echo two 20 | return 1 21 | } 22 | 23 | print_two_lines_without_final_newline() { 24 | echo one 25 | echo -n two 26 | } 27 | 28 | print_three_lines_without_final_newline() { 29 | echo one 30 | echo two 31 | echo -n three 32 | } 33 | 34 | double_line() { 35 | echo "$@ $@" 36 | } 37 | 38 | fail_on_two() { 39 | echo "$@ $@" 40 | if [ "$@" = "two" ]; then 41 | return 1 42 | fi 43 | } 44 | 45 | @test "map_lines: no command given" { 46 | fn() { 47 | echo one | map_lines 48 | } 49 | run fn 50 | assert_equal "line 1" "error: no command given to map_lines" "${lines[0]}" 51 | assert_equal "line 2" "usage: map_lines COMMAND" "${lines[1]}" 52 | assert_equal "number of lines" 2 ${#lines[@]} 53 | assert_equal "exit code" 2 $status 54 | } 55 | 56 | @test "map_lines: one line" { 57 | fn() { 58 | echo one | map_lines double_line 59 | } 60 | run fn 61 | assert_equal "line 1" "one one" "${lines[0]}" 62 | assert_equal "number of lines" 1 ${#lines[@]} 63 | assert_equal "exit code" 0 $status 64 | } 65 | 66 | @test "map_lines: one line without newline" { 67 | fn() { 68 | echo -n one | map_lines double_line 69 | } 70 | run fn 71 | assert_equal "line 1" "one one" "${lines[0]}" 72 | assert_equal "number of lines" 1 ${#lines[@]} 73 | assert_equal "output" "one one" "$output" 74 | assert_equal "exit code" 0 $status 75 | } 76 | 77 | @test "map_lines: three lines" { 78 | fn() { 79 | print_three_lines | map_lines double_line 80 | } 81 | run fn 82 | assert_equal "line 1" "one one" "${lines[0]}" 83 | assert_equal "line 2" "two two" "${lines[1]}" 84 | assert_equal "line 3" "three three" "${lines[2]}" 85 | assert_equal "number of lines" 3 ${#lines[@]} 86 | assert_equal "exit code" 0 $status 87 | } 88 | 89 | @test "map_lines: failure of line function stops execution" { 90 | fn() { 91 | print_three_lines | map_lines fail_on_two 92 | } 93 | run fn 94 | assert_equal "line 1" "one one" "${lines[0]}" 95 | assert_equal "line 2" "two two" "${lines[1]}" 96 | assert_equal "number of lines" 2 ${#lines[@]} 97 | assert_equal "exit code" 1 $status 98 | } 99 | 100 | @test "map_lines: failure of line function propagates status code" { 101 | fn() { 102 | print_three_lines | map_lines fail_on_two 103 | } 104 | run fn 105 | assert_equal "exit code" 1 $status 106 | } 107 | 108 | @test "map_lines: failure of mapped function propagates status code" { 109 | fn() { 110 | print_two_lines_then_fail | map_lines double_line 111 | } 112 | run fn 113 | assert_equal "exit code" 1 $status 114 | } 115 | 116 | @test "map_lines: three lines without final newline" { 117 | fn() { 118 | print_three_lines_without_final_newline | map_lines double_line 119 | } 120 | run fn 121 | assert_equal "line 1" "one one" "${lines[0]}" 122 | assert_equal "line 2" "two two" "${lines[1]}" 123 | assert_equal "line 3" "three three" "${lines[2]}" 124 | assert_equal "number of lines" 3 ${#lines[@]} 125 | assert_equal "exit code" 0 $status 126 | } 127 | 128 | @test "map_lines: three lines without final newline, failure on second line" { 129 | fn() { 130 | print_three_lines_without_final_newline | map_lines fail_on_two 131 | } 132 | run fn 133 | assert_equal "line 1" "one one" "${lines[0]}" 134 | assert_equal "line 2" "two two" "${lines[1]}" 135 | assert_equal "number of lines" 2 ${#lines[@]} 136 | assert_equal "exit code" 1 $status 137 | } 138 | 139 | @test "map_lines: two lines without final newline, failure on second line" { 140 | fn() { 141 | print_two_lines_without_final_newline | map_lines fail_on_two 142 | } 143 | run fn 144 | assert_equal "line 1" "one one" "${lines[0]}" 145 | assert_equal "line 2" "two two" "${lines[1]}" 146 | assert_equal "number of lines" 2 ${#lines[@]} 147 | assert_equal "exit code" 1 $status 148 | } 149 | 150 | @test "map_lines: command with arguments" { 151 | fn() { 152 | print_three_lines | map_lines echo argument1 argument2 153 | } 154 | run fn 155 | assert_equal "line 1" "argument1 argument2 one" "${lines[0]}" 156 | assert_equal "line 2" "argument1 argument2 two" "${lines[1]}" 157 | assert_equal "line 3" "argument1 argument2 three" "${lines[2]}" 158 | assert_equal "number of lines" 3 ${#lines[@]} 159 | assert_equal "exit code" 0 $status 160 | } 161 | 162 | @test "pick_color: fail gracefully when no argument" { 163 | run pick_color 164 | assert_equal "output" "" "$output" 165 | assert_equal "exit code" 0 $status 166 | } 167 | 168 | @test "pick_color: sample a color" { 169 | run pick_color 4 170 | assert_equal "output" "\033[31m" "$output" 171 | assert_equal "exit code" 0 $status 172 | } 173 | 174 | @test "source_dotenv: source .env" { 175 | fixture basic_env 176 | unset DOT 177 | source_dotenv .env 178 | assert_equal '$DOT' "-" "$DOT" 179 | unset DOT 180 | } 181 | 182 | @test "source_dotenv: source env with nonstandard filepath" { 183 | fixture basic_env_with_nonstandard_envfile 184 | unset DOT 185 | source_dotenv env 186 | assert_equal '$DOT' "-" "$DOT" 187 | unset DOT 188 | } 189 | 190 | do_parse_procfile_line() {\ 191 | parse_procfile_line NAME COMMAND "$1"; 192 | echo $NAME 193 | echo $COMMAND 194 | } 195 | 196 | @test "parse_procfile_line: statement" { 197 | run do_parse_procfile_line "foo: echo bar" 198 | assert_equal "name" "foo" "${lines[0]}" 199 | assert_equal "command" "echo bar" "${lines[1]}" 200 | } 201 | 202 | @test "parse_procfile_line: blank line" { 203 | run do_parse_procfile_line "" 204 | assert_equal "name" "" "${lines[0]}" 205 | assert_equal "command" "" "${lines[1]}" 206 | } 207 | 208 | @test "parse_procfile_line: whitespace-only line" { 209 | run do_parse_procfile_line " " 210 | assert_equal "name" "" "${lines[0]}" 211 | assert_equal "command" "" "${lines[1]}" 212 | } 213 | 214 | @test "parse_procfile_line: comment" { 215 | run do_parse_procfile_line " # this is a comment"; 216 | assert_equal "name" "" "${lines[0]}" 217 | assert_equal "command" "" "${lines[1]}" 218 | } 219 | 220 | @test "parse_procfile_line: comment preceded by tab" { 221 | run do_parse_procfile_line " # this is a comment"; 222 | assert_equal "name" "" "${lines[0]}" 223 | assert_equal "command" "" "${lines[1]}" 224 | } 225 | 226 | @test "parse_procfile_line: statement with comment" { 227 | run do_parse_procfile_line "foo: echo hello # this is a comment"; 228 | assert_equal "name" "foo" "${lines[0]}" 229 | assert_equal "command" "echo hello # this is a comment" "${lines[1]}" 230 | } 231 | 232 | @test "parse_procfile_line: statement with hash in quotes" { 233 | run do_parse_procfile_line "foo: echo '#hello'"; 234 | assert_equal "name" "foo" "${lines[0]}" 235 | assert_equal "command" "echo '#hello'" "${lines[1]}" 236 | } 237 | 238 | @test "parse_procfile_line: statement with hash in expression" { 239 | run do_parse_procfile_line 'foo: echo ${0##*/}: There are $# arguments'; 240 | assert_equal "name" "foo" "${lines[0]}" 241 | assert_equal "command" 'echo ${0##*/}: There are $# arguments' "${lines[1]}" 242 | } 243 | 244 | --------------------------------------------------------------------------------