doco parses options and commands with support for GNU-like short and long options. Multiple usage patterns are supported, including breaking strings like -xyzabc
into -x -y -z=abc
(if -z
takes an argument and -x
and -y
don't). Option values can use explicit =
(e.g. --foo=bar
, -z=q
), separate arguments (--foo bar
, -z q
), or even optional arguments (where =
invokes a different code path than the standalone option).
doco_had_args=0 # times doco has been called with arguments
loco_do() {
project-is-finalized ||
fail "doco CLI cannot be used before the project spec is finalized" || return
(( ! $# )) || doco_had_args=1
case ${1-} in
--*=*) doco-optarg "$@" ;; # --[option]=value
--*) doco-option "$@" ;; # --[option]
-[^=]=*) doco-optarg "$@" ;; # -a=bcd
-[^=]?*) doco-options "$@" ;; # -abcd
-?) doco-option "$@" ;; # -x
'') doco-null "$@" ;; # empty or missing command
*) doco-other "$@" ;; # commands, services, and groups
esac
}
If no arguments remain on the command line, doco
outputs the current target service list, one item per line, and returns success. However, if there are no arguments left because none were ever given, a usage message is output:
doco-null() {
if ((doco_had_args && ! $#)); then
target "@current" get
${REPLY[@]+printf '%s\n' "${REPLY[@]}"} # only output lines if there are some
else
loco_usage # No non-empty command given and no targets specified, exit w/usage
fi
}
Options are defined using functions whose names begin with doco.
, followed by -
or --
, the option name, and optionally an =
. If the option name ends with an =
, it requires an argument, which can be supplied as a separate argument (e.g. --foo bar
or -f bar
), or as part of the same argument (e.g. --foo=bar
or -f=bar
or -fbar
). If a given option has functions for both =
and non-=
variants, the non-=
variant will be called for the standalone option (--foo
or -f
).
doco-options() {
if fn-exists "doco.${1:0:2}="; then
"doco.${1:0:2}=" "${1:2}" "${@:2}" # -a= bcd ...
elif fn-exists "doco.${1:0:2}"; then
"doco.${1:0:2}" "-${1:2}" "${@:2}" # -a -bcd ...
else doco-other "$@" # maybe -abcd is a command, group, or service?
fi
}
doco-option() {
if fn-exists "doco.$1"; then "doco.$@"
elif fn-exists "doco.$1="; then
if (($#>1)); then "doco.$1=" "${@:2}"
else fail "$1 requires an argument"
fi
else doco-other "$@" # maybe --longopt is a group or service?
fi
}
doco-optarg() {
if fn-exists "doco.${1%%=*}="; then
"doco.${1%%=*}=" "${1#*=}" "${@:2}"
elif fn-exists "doco.${1%%=*}"; then
fail "${1%%=*} does not accept values"
else doco-other "$@" # maybe it's a command/group/service?
fi
}
If a name passed to doco on the command line isn't recognized as an option, it's checked for a subcommand function (doco.X
, where X
is the possible subcommand). If that doesn't work, we fall back to see if X
is a service or group defined by the configuration. If so, the services it targets are added to the "current target" set, and command parsing continues with the next argument. So, if a
and b
are services or groups, then doco a b ps
is roughly equivalent to doco ps a b
. (Except that if say, b
is a group containing c
and d
, it'd be equivalent to doco ps a c d
instead.)
doco-other() {
if fn-exists "doco.$1"; then
with-command "${DOCO_COMMAND:-$1}" "doco.$@"
elif is-target-name "$1" && target "$1" exists; then
with-targets @current "$1" -- doco "${@:2}"
else fail "'$1' is not a recognized option, command, service, or group"
fi
}
As part of subcommand recognition, doco keeps track of the first subcommand executed since a change of targets. This is so that when a subcommand executes other subcommands, doco knows to still check defaults for the calling command.
For example, since doco sh
calls doco exec
, invoking sh
sets the DOCO_COMMAND
to sh
, even when exec
runs. (Then, the order of default groups checked is --sh-default
, --exec-default
, and finally --default
). The with-command
function handles setting a local value of DOCO_COMMAND
while running an abritrary command.
with-command() { local DOCO_COMMAND=$1; "${@:2}"; }
Reset the active service set to empty (and non-existent). In terms of target selection, everything after the --
executes as if it were the first thing on the command line passed to doco, with any prior targets discarded.
If no services are explicitly added after this point in the command line, then docker-compose subcommands will have their default behavior and argument parsing. (That is, commands that take multiple services will apply to all services unless a service is listed, and commands that apply to a single service will require it as the first post-option argument.)
# Execute the rest of the command line without specified services
doco.--() { without-targets doco "$@"; }
Update the service set to include all services for the remainder of the command line (unless reset again with --
). Note that this is different from executing normal docker-compose commands with an explicitly empty set (e.g. using --
or an empty group), in that it explicitly passes along all the service names. (Among other things, this lets you use commands like foreach
to run single-target commands (e.g. exec
) against each service.)
(Note: this option is actually implemented as a built-in GROUP
, defined immediately after the project configuration is generated.)
Output any docker or docker-compose commands that would be issued, instead of actually running them.
doco.--dry-run() {
docker() { printf -v REPLY ' %q' "docker" "$@"; echo "${REPLY# }"; } >&2
docker-compose() { printf -v REPLY ' %q' "docker-compose" "$@"; echo "${REPLY# }"; } >&2
((! $#)) || { doco "$@"; unset -f docker docker-compose; }
}
Add services matching jq-filter to the current service set for the remainder of the command line. If this is the last thing on the command line, outputs service names to stdout, one per line. The filter is a jq expression that will be applied to the body of a service definition as it appears in the form provided to docker-compose. (That is, values supplied by compose via extends
or variable interpolation are not available.)
function doco.--where=() {
services-matching "${@:1}"
with-targets @current "${REPLY[@]}" -- doco "${@:2}" # run command on matching services
}
The --with
option adds one or more services or groups to the current service set for the remainder of the command line, unless reset with --
. The target argument is either a single service or group name, or a string containing a space-separated list of service or group names. --with
can be given more than once. To reset the service set to empty, use --
.
# Execute the rest of the command line with specified service(s)
function doco.--with=() {
mdsh-splitwords "$1"; with-targets @current "${REPLY[@]}" -- doco "${@:2}"
}
Note that you don't normally need to use this option, because you can simply run doco
targets... commands... in the first place. It's really only useful in cases where you have service or group names that might conflict with other subcommand names, or need to use a set of group/service names stored in a non-array variable (e.g. in a .env
file)
Invoke doco
subcommand args..., adding target to the current service set if the current set is empty. Note that target could still be nonexistent or empty, so you may wish to follow this option with --require-services
to verify the new count.
function doco.--with-default=() {
if target @current has-count || ! target "$1" exists; then doco "${@:2}"
else with-targets "$1" -- with-command "${DOCO_COMMAND-}" doco "${@:2}"; fi
}
This is the command-line equivalent of calling require-services
flag subcommand before invoking subcommand args.... That is, it checks that the relevant number of services are present and exits with a usage error if not. The flag argument can include a space and a command name to be used in place of subcommand in any error messages.
function doco.--require-services=() {
[[ ${1:0:1} == [-+1.] ]] || loco_error "--require-services argument must begin with ., -, +, or 1"
mdsh-splitwords "$1"; ((${#REPLY[@]}>1)) || REPLY+=("${DOCO_COMMAND:-${2-}}")
quantify-services "${REPLY[@]:0:2}" "${DOCO_SERVICES[@]}" && doco "${@:2}"
}
Verify the number of services in the current target, after applying defaults if the current service set is undefined. Defaults are looked up for the current command, any explicitly specified cmd words included in $1
, subcommand, and the global default target. If the verification succeeds, doco
subcommand... is run with an explicit service set matching the first default group found (or with the same service set).
The first argument after cmd
must begin with a quantifier suitable for use with quantify-services
, and may optionally include whitespace-separated command names. If given, these names will be treated as commands whose defaults should be searched. (The exact lookup order for defaults is LOCO_COMMAND
, followed by any supplied cmd words, followed by subcommand, followed by --default
.)
doco.cmd() {
[[ ${DOCO_COMMAND-} != cmd ]] || local DOCO_COMMAND
[[ ${1-} == [-+1.]* ]] || quantify-services "${1-}" || return
local cmds; mdsh-splitwords "${1-}" cmds; cmds+=("${@:2:1}")
compose-defaults "${cmds[@]:1}" || true
quantify-services "${cmds[0]}" "${cmds[1]-}" "${REPLY[@]}" || return
with-targets "${REPLY[@]}" -- doco "${@:2}"
}
The doco config
command differs from docker-compose config
in that its output is paged if sent to the console. The YAML is also colorized if pygmentize
is available. You can set the pager used with DOCO_PAGER
, and replace the colorization command by setting DOCO_YAML_COLOR
.
Copy a file in or out of a service container. Functions the same as docker cp
, except that instead of using a container name as a prefix, you can use either a service name or an empty string (meaning, the currently-selected service). So, e.g. doco cp :/foo bar
copies /foo
from the current service to bar
, while doco cp baz spam:/thing
copies baz
to /thing
inside the spam
service's first container. If no service is selected and no service name is given, the --cp-default
, --sh-default
, --exec-default
, and --default
targets are tried.
doco.cp() {
local opts=() seen=''
while (($#)); do
case "$1" in
-a|--archive|-L|--follow-link) opts+=("$1") ;;
--help|-h) docker help cp || true; return ;;
-*) fail "Unrecognized option $1; see 'docker help cp'" || return ;;
*) break ;;
esac
shift
done
(($# == 2)) || fail "cp requires two non-option arguments (src and dest)" || return
while (($#)); do
if [[ $1 == *:* ]]; then
[[ ! "$seen" ]] || fail "cp: only one argument may contain a :" || return
seen=yes
if [[ "${1%%:*}" ]]; then
project-name "${1%%:*}"; set -- "$REPLY:${1#*:}" "${@:2}"
else
compose-defaults cp sh exec || true
quantify-services 1 cp "${REPLY[@]}" || return
project-name "$REPLY"; set -- "$REPLY$1" "${@:2}"
fi
elif [[ $1 != /* && $1 != - ]]; then
# make paths relative to original run directory
set -- "$LOCO_PWD/$1" "${@:2}";
fi
opts+=("$1"); shift
done
[[ "$seen" ]] || fail "cp: either source or destination must contain a :" || return
docker cp ${opts[@]+"${opts[@]}"}
}
Execute the given doco
subcommand once for each service in the current service set, with the service set restricted to a single service for each subcommand invocation. This can be useful for explicit multiple (or zero) execution of a command that is otherwise restricted in how many times it can be executed.
doco.foreach() { target @current foreach doco "$@"; }
doco jq
args... pipes the docker-compose configuration to jq
args... as JSON. The JSON is the contents of the configuration as it appears in the form provided to docker-compose. (That is, values supplied by compose via extends
or variable interpolation will not be visible.)
Any functions defined via jqmd's facilities (DEFINES
, IMPORTS
, jq defs
blocks, const
blocks, etc.) will be available to the given jq expression, if any. If no expression is given, .
is used.
If stdout is a TTY, the output is paged (using $DOCO_PAGER
or less -FRX
) and colorized by jq.
doco.jq() { local JQ_CMD=(jq-tty); RUN_JQ "$@" <"$DOCO_CONFIG"; }
doco jqc
args... pipes the complete docker-compose configuration to jq
args... as JSON. The JSON is generated by converting the output of docker-compose config
from YAML to JSON, making this command slower than doco jq
with the same arguments, but the effects of extends
, variable interpolation, etc. will be available.
If stdout is a TTY, the output is paged (using $DOCO_PAGER
or less -FRX
) and colorized by jq.
doco.jqc() { compose-config; local JQ_CMD=(jq-tty); RUN_JQ "$@" <<<"$COMPOSED_JSON"; }
doco sh
args... executes bash
args in the specified service's container. Multiple services are not allowed, unless you preface sh
with foreach
.
doco.sh() { doco exec bash "$@"; }
Tag the current service's image
with tags. If no tags are given, outputs the service's image
.
If a tag contains a :
, it is passed to the docker tag
command as-is. Otherwise, if it contains a /
, :latest
will be added to the end of it. If it contains neither a :
nor a /
, it is appended to the base image with a :
.
That is, if a service foo
has an image
of foo/bar:1.2
then:
doco foo tag bar/baz:bing
will tag the image asbar/baz:bing
doco foo tag bar/baz
will tag the image asbar/baz:latest
doco foo tag latest
will tag the image asfoo/bar:latest
doco foo tag baz
will tag the image asfoo/bar:baz
Exactly one service must be selected, either explicitly or via the --tag-default
or--default
targets. The service must have an image
key, or the command will fail.
(Note: this command tags the image specified by the service's image
setting, not the image currently in use by the service. If the image
changed (or there's a newer image with that tag) since the last service up
, you may be tagging the wrong image.)
doco.tag() {
require-services 1 tag || return
set -- "$(CLEAR_FILTERS; FILTER 'services[%s].image' "$REPLY"; RUN_JQ -r <"$DOCO_CONFIG")" "$@"
(($#>1))||{ echo "$1"; return; }
for REPLY in "${@:2}"; do
case $REPLY in
?*:*) docker tag "$1" "$REPLY" ;;
*/*) docker tag "$1" "$REPLY:latest" ;;
*) docker tag "$1" "${1%:*}:$REPLY";;
esac
done
}