keep the dynamism of make in pure shell

Two mechanisms, matching what the make engine actually did:

Lazy defaults. A stack setting is a function myos_default_<VAR>, called only
when the variable has no value, and called again at every reference. That is
exactly a recursive ?=: an explicit value wins, and the default follows a
DOMAIN that a .env changes later. The prefix is what makes it safe; the first
version used a bare function named after the variable, and the test suite
caught it running /usr/bin/host for a stack group called host.

Templates. myos env-update fills a .env from the .env.dist files, expanding
${VAR} against the current values and running $(command), forward references
included.

Also fixed: the project .env now wins over /etc/conf.d/myos, which is what the
documentation claimed and the code did not.

share/make/shim.mk lets a project keep make as a front end: every myos command
becomes a target that shells out to bin/myos, and the project keeps its own
targets and its stack .mk files. It sits outside make/ because the legacy
engine globs every .mk in there.
This commit is contained in:
Yann Autissier
2026-09-03 20:46:28 +02:00
parent f541ca418b
commit 55fae625d6
33 changed files with 571 additions and 25 deletions
+8
View File
@@ -12,6 +12,14 @@
- agent skill in `skills/myos/`, contributor notes in `AGENTS.md`
- stacks carry their settings in `<name>.env` and `<name>.sh` hooks, so the
catalogue no longer needs make to be installed
- lazy defaults (`myos_default_<VAR>` functions) give the recursive `?=` of
make in pure shell: an explicit value wins, and the default is recomputed
at each reference
- `myos env-update` generates a `.env` from the `.env.dist` templates,
expanding `${VAR}` and `$(command)`, including forward references
- the project `.env` now wins over `/etc/conf.d/myos`, as documented;
`MYOS_CONF_PRIORITY=system` restores the previous order
- `share/make/shim.mk`: make as an optional front end over the same shell code
- `--color always|never|auto`, and no colour when the output is piped
- verified under the /bin/sh of Alpine (busybox) and Debian (dash)
- the make engine still works and is still covered by the golden tests
+13
View File
@@ -124,6 +124,19 @@ The make targets keep working: `print-VAR`, `stack-<stack>-<command>`,
`SETUP_UFW=true` enables the ufw/ufw-docker integration (`myos setup-ufw`).
## With make
A project that would rather drive make can include the shim, which turns every
myos command into a make target while leaving its own targets alone:
```make
MYOS ?= /usr/local/lib/myos
include $(MYOS)/share/make/shim.mk
```
`make up STACK=host` then runs exactly what `myos up host` runs: the shim only
forwards. make is not needed otherwise, and the CLI never calls it.
## For agents
`skills/myos/SKILL.md` is a skill describing how to drive myos, with
+27 -7
View File
@@ -19,7 +19,7 @@ done
MYOS_ROOT=$(cd "$(dirname "$_self")/.." && pwd -P)
export MYOS_ROOT
for _m in core str tags naming stack config compose hooks; do
for _m in core str var tags naming stack config compose hooks; do
# shellcheck source=/dev/null
. "$MYOS_ROOT/lib/$_m.sh"
done
@@ -58,6 +58,7 @@ Commands:
ps logs config exec run inspect and enter them
ls [--groups] list the stacks myos can see
env [VAR...] show resolved variables
env-update fill .env from the .env.dist templates
doctor check the installation
version print the myos version
@@ -121,11 +122,30 @@ esac
WORKDIR=${WORKDIR:-$PWD}
WORKDIR=$(cd "$WORKDIR" 2>/dev/null && pwd -P) || myos_die "$MYOS_E_USAGE" "no such directory: $WORKDIR"
for _f in $(myos_conf_files); do myos_dotenv_load "$_f"; done
myos_dotenv_load "${HOME:-}/.config/myos/config"
# ENV decides which .env.<env> to read, so it is resolved first, from the most
# specific source that names it.
if [ -z "${ENV:-}" ]; then
for _f in "$WORKDIR/.env" "${HOME:-}/.config/myos/config" $(myos_conf_files); do
ENV=$(myos_dotenv_parse "$_f" | sed -n 's/^ENV=//p' | tail -1)
[ -n "$ENV" ] && break
done
fi
ENV=${ENV:-local}
myos_dotenv_load "$WORKDIR/.env.$ENV"
myos_dotenv_load "$WORKDIR/.env"
# The layers, most specific first: the loader never overwrites a value, so the
# order below is the order of precedence. The environment and the VAR=value
# arguments are already set, and therefore win over every file.
# MYOS_CONF_PRIORITY=system puts the machine files first, as the make engine did.
myos_config_layers() {
if [ "${MYOS_CONF_PRIORITY:-}" = system ]; then
myos_conf_files
printf '%s\n' "${HOME:-}/.config/myos/config" "$WORKDIR/.env.$ENV" "$WORKDIR/.env"
else
printf '%s\n' "$WORKDIR/.env.$ENV" "$WORKDIR/.env" "${HOME:-}/.config/myos/config"
myos_conf_files
fi
}
for _f in $(myos_config_layers); do myos_dotenv_load "$_f"; done
# Overlay switches. Their names drive which <stack>.<suffix>.yml files load,
# so these defaults decide that e.g. supabase.labels.yml is picked up.
@@ -159,7 +179,7 @@ if [ -z "$MYOS_REFS" ]; then
fi
if [ -z "$MYOS_REFS" ]; then
case $MYOS_CMD in
env|ls|doctor|version|help) ;;
env|env-update|ls|doctor|version|help) ;;
*) myos_die "$MYOS_E_USAGE" "no stack given, and no compose file in $WORKDIR" ;;
esac
fi
@@ -211,7 +231,7 @@ esac
# shellcheck source=/dev/null
if [ -f "$MYOS_ROOT/lib/cmd/$MYOS_CMD.sh" ]; then
. "$MYOS_ROOT/lib/cmd/$MYOS_CMD.sh"
"myos_cmd_$MYOS_CMD"
"myos_cmd_$(printf '%s' "$MYOS_CMD" | tr '-' '_')"
exit $?
fi
+26
View File
@@ -0,0 +1,26 @@
#shellcheck shell=sh
# myos env-update fill the .env of the workdir from the .env.dist it finds
#
# Templates are read from the workdir and from every requested stack, so a
# stack can ship the variables it expects and their defaults.
myos_cmd_env_update() {
_target=${ENV_FILE:-$WORKDIR/.env}
_dists=$MYOS_ARGS
if [ -z "$_dists" ]; then
for _ref in $MYOS_STACKS; do
_d=$(myos_stack_resolve "$_ref" 2>/dev/null) || continue
for _c in "$_d/.env.dist" "$_d/$(myos_stack_name "$_ref").env.dist"; do
[ -f "$_c" ] && _dists="${_dists:+$_dists }$_c"
done
done
for _c in "$WORKDIR/.env.dist" "$WORKDIR/.env.example" "$WORKDIR/.env.sample"; do
[ -f "$_c" ] && _dists="${_dists:+$_dists }$_c"
done
fi
[ -n "$_dists" ] || { myos_warning "no .env.dist found for $MYOS_STACKS"; return 0; }
for _dist in $_dists; do
myos_info "env-update $_target from $_dist"
myos_env_update "$_target" "$_dist" "$WORKDIR/.env.$ENV"
done
printf '%s\n' "$_target"
}
+91
View File
@@ -80,3 +80,94 @@ myos_env_export() {
done
return 0
}
# myos_expand STRING substitute ${VAR} and $(command) in STRING.
# shellcheck disable=SC2016 # the single quotes are deliberate: these patterns
# match the literal characters ${ and $( in the input, they are not expansions
# This is what the make engine did when it generated a .env out of a .env.dist:
# ${VAR} takes the current value, $(cmd) runs the command. Nothing else is
# interpreted, so the rest of the line can hold anything.
myos_expand() {
_in=$1
_guard=0
while [ "$_guard" -lt 16 ]; do
_guard=$((_guard + 1))
case $_in in
*'${'*'}'*)
_pre=${_in%%'${'*}
_rest=${_in#*'${'}
_name=${_rest%%\}*}
_post=${_rest#*\}}
case $_name in
''|*[!A-Za-z0-9_]*) _in="$_pre\${$_name}$_post"; break ;;
esac
_in="$_pre$(myos_var "$_name")$_post" ;;
*) break ;;
esac
done
_guard=0
while [ "$_guard" -lt 16 ]; do
_guard=$((_guard + 1))
case $_in in
*'$('*')'*)
_pre=${_in%%'$('*}
_rest=${_in#*'$('}
_cmd=${_rest%%)*}
_post=${_rest#*)}
_in="$_pre$(eval "$_cmd" 2>/dev/null)$_post" ;;
*) break ;;
esac
done
printf '%s' "$_in"
}
# myos_env_update FILE DIST [OVER...]
# Add to FILE every variable of DIST that is missing from it, expanded.
# A variable that already has a value keeps it, whether it comes from the
# environment, from FILE, or from one of the OVER files: the .env is a record
# of the choices already made, never something that overwrites them.
myos_env_update() {
_file=$1; _dist=$2; shift 2
[ -f "$_dist" ] || return 0
[ -e "$_file" ] || : > "$_file"
# what the overrides pin, read before anything else
for _over in "$@"; do
[ -f "$_over" ] || continue
myos_dotenv_load "$_over"
done
# Which keys already hold a choice, made in the environment, in the .env or
# in an override. Those are kept verbatim; everything else is a template.
_preset=" "
while IFS= read -r _kv; do
[ -n "$_kv" ] || continue
_k=${_kv%%=*}
[ -n "$_k" ] || continue
[ -n "$(myos_var "$_k")" ] && _preset="$_preset$_k "
done <<EOF
$(myos_dotenv_parse "$_dist")
EOF
# Make the templates themselves visible, so a line may refer to a variable
# defined further down the file, as the make engine allowed.
myos_dotenv_load "$_dist"
_added=0
while IFS= read -r _kv; do
[ -n "$_kv" ] || continue
_k=${_kv%%=*}
[ -n "$_k" ] || continue
myos_dotenv_has "$_file" "$_k" && continue
case $_preset in
*" $_k "*) _v=$(myos_var "$_k") ;;
*) _v=$(myos_expand "${_kv#*=}") ;;
esac
printf '%s=%s\n' "$_k" "$_v" >> "$_file"
_added=$((_added + 1))
done <<EOF
$(myos_dotenv_parse "$_dist")
EOF
[ "$_added" -gt 0 ] && myos_info "added $_added variable(s) to $_file"
return 0
}
+3 -6
View File
@@ -4,12 +4,9 @@
# Ported from make/apps/def.mk (uri, url, urlprefix, urlprefixs, tagprefix,
# envprefix, servicenvs). Registrator publishes the SERVICE_<port>_TAGS label
# to consul, fabio routes on the urlprefix- tags it finds there.
# myos_var NAME value of the variable named NAME, empty when unset
myos_var() {
[ -n "${1:-}" ] || return 0
eval "printf '%s' \"\${$1:-}\""
}
#
# Every lookup goes through myos_var (lib/var.sh), so a stack setting may be a
# plain value or a lazy default, and the two behave the same here.
# myos_uri SERVICE PORT [BASE_URI]
# <service>.<base uri>, unless <SERVICE>_SERVICE[_<port>]_NAME overrides the prefix
+56
View File
@@ -0,0 +1,56 @@
#shellcheck shell=sh
# var: variable resolution, with the lazy defaults the make engine had.
#
# make gives every `VAR ?= $(call ...)` two properties at once: an explicit
# value wins, and the default is re-evaluated at each reference, so it sees
# whatever a .env loaded later has changed.
#
# The shell gets both by keeping defaults in functions: `myos_var NAME` reads
# the variable when it has a value, and otherwise calls the function
# `myos_default_NAME`. The prefix matters: a bare function named after the
# variable would collide with commands on PATH, and a stack setting called
# `host` or `test` would then run a program instead of returning a value.
#
# myos_default_HOST_FABIO_SERVICE_9998_TAGS() { myos_tagprefix HOST_FABIO 9998; }
#
# is the exact equivalent of
#
# HOST_FABIO_SERVICE_9998_TAGS ?= $(call tagprefix,HOST_FABIO,9998)
MYOS_VAR_MAX_DEPTH=${MYOS_VAR_MAX_DEPTH:-32}
# myos_var NAME the value of NAME: the variable if it has one, else the lazy
# default, else empty.
myos_var() {
[ -n "${1:-}" ] || return 0
eval "_myos_set=\${$1+yes}"
if [ "${_myos_set:-}" = yes ]; then
eval "printf '%s' \"\$$1\""
return 0
fi
myos_var_is_lazy "$1" || return 0
# a default written in terms of itself would loop for ever
_myos_depth=$((${MYOS_VAR_DEPTH:-0} + 1))
if [ "$_myos_depth" -gt "$MYOS_VAR_MAX_DEPTH" ]; then
myos_error "variable $1 is defined in terms of itself"
return 1
fi
MYOS_VAR_DEPTH=$_myos_depth "myos_default_$1"
}
# myos_default NAME BODY declare a lazy default from a string, for callers
# that build the variable name at run time
myos_default() {
eval "myos_default_$1() { $2; }"
}
# myos_var_is_lazy NAME true when NAME has no value but has a lazy default
myos_var_is_lazy() {
eval "_myos_set=\${$1+yes}"
[ "${_myos_set:-}" = yes ] && return 1
# a shell function, never a command on PATH: command -v prints the name back
# for a function and a path for a program
_myos_fn=$(command -v "myos_default_$1" 2>/dev/null) || return 1
[ "$_myos_fn" = "myos_default_$1" ]
}
+57
View File
@@ -0,0 +1,57 @@
##
# myos, from make.
#
# This file gives a project the myos commands as make targets, without the make
# engine: every target shells out to bin/myos, which is pure POSIX sh. What the
# project keeps from make is what make is actually good at, and the CLI is not:
# its own targets, its own dependencies, and the .mk files of its stacks.
#
# MYOS ?= /usr/local/lib/myos
# include $(MYOS)/share/make/shim.mk
#
# It lives outside make/ on purpose: the legacy engine includes every .mk of
# that directory, and would pull this one in too.
#
# Then `make up STACK=host` and `myos up host` do the same thing, through the
# same code. Variables given on the command line are forwarded, so
# `make up STACK=host DOMAIN=example.org` behaves as expected.
MYOS ?= $(patsubst %/share/make/shim.mk,%,$(lastword $(MAKEFILE_LIST)))
MYOS_BIN ?= $(MYOS)/bin/myos
STACK_DIR_NAME ?= stack
STACK_DIR ?= $(wildcard $(CURDIR)/$(STACK_DIR_NAME))
# variable MYOS_ARGS: variables set on the make command line, forwarded to myos
MYOS_ARGS ?= $(foreach v,$(MAKEOVERRIDES),$(v))
.DEFAULT_GOAL := help
## the stack files of the project may add their own targets and variables
include $(wildcard $(STACK_DIR)/*.mk $(STACK_DIR)/*/*.mk)
# function make: run a myos command, for the stack .mk files that call it
define make
$(MYOS_BIN) $(MYOS_ARGS) $(1)
endef
# target help: List the myos commands
.PHONY: help
help:
@$(MYOS_BIN) help
# target myos: Run an arbitrary myos command, as in `make myos ARGS="up host"`
.PHONY: myos
myos:
@$(MYOS_BIN) $(MYOS_ARGS) $(ARGS)
# make tries to remake every makefile it read, and the catch-all below would
# hand each of them to myos as a command. An empty rule stops that.
$(MAKEFILE_LIST): ;
# target %: Hand anything else to myos
## a target the project defines itself keeps precedence over this rule
%: FORCE
@$(MYOS_BIN) $(MYOS_ARGS) $@ $(ARGS)
.PHONY: FORCE
FORCE: ;
+4 -2
View File
@@ -7,7 +7,7 @@ stack/<name>/<name>.yml the services
stack/<name>/<name>.local.yml what only makes sense on a workstation (published ports…)
stack/<name>/<name>.labels.yml the registrator labels, so routing stays optional
stack/<name>/<name>.env plain settings: versions, defaults
stack/<name>/<name>.sh computed settings (fabio tags), no make needed
stack/<name>/<name>.sh lazy defaults (fabio tags), no make needed
stack/<name>/.env.dist the variables it expects, with defaults
stack/<name>/README.md what it is and what it needs
```
@@ -54,7 +54,9 @@ lib/core.sh logging, exit codes, dry run
lib/str.sh strings and version comparison
lib/naming.sh project names, networks, user identity
lib/stack.sh stack path, references, overlays, groups
lib/config.sh dotenv, variables of the compose files
lib/var.sh variable resolution and lazy defaults
lib/config.sh dotenv, templates, variables of the compose files
lib/hooks.sh the per-stack .env and .sh
lib/compose.sh finding and calling docker compose
lib/tags.sh fabio tags
lib/cmd/<x>.sh one file per command
+1
View File
@@ -26,6 +26,7 @@ myos [options] <command> [stack...] [VAR=value...] [-- args...]
| `build` / `pull` | images |
| `ls [--groups]` | the stacks and groups myos can see |
| `env [VAR...]` | resolved variables |
| `env-update` | fill the `.env` from the `.env.dist` templates |
| `doctor` | check the installation |
| `version` | the myos version |
+36 -8
View File
@@ -110,21 +110,49 @@ A stack keeps its own settings next to its compose files:
| `<name>.sh` | values that have to be computed |
| `<name>.mk` | the legacy make snippet; still read for its groups |
A `.sh` hook is sourced with the myos helpers available, and sets variables
directly. This is what lets a stack work on a machine that has no make:
A `.sh` hook declares **lazy defaults**: a function named
`myos_default_<VARIABLE>`, called only when the variable has no value, and
called again at each reference. That is the make `?=` on a recursive variable,
in shell:
```sh
# stack/host/fabio.sh
HOST_FABIO_VERSION=${HOST_FABIO_VERSION:-1.6.3}
HOST_FABIO_SERVICE_9998_NAME=${HOST_FABIO_SERVICE_9998_NAME:-fabio}
HOST_FABIO_SERVICE_9998_AUTH=${HOST_FABIO_SERVICE_9998_AUTH:-default}
HOST_FABIO_SERVICE_9998_TAGS=${HOST_FABIO_SERVICE_9998_TAGS:-$(myos_tagprefix HOST_FABIO 9998)}
myos_default_HOST_FABIO_VERSION() { printf '1.6.3'; }
myos_default_HOST_FABIO_SERVICE_9998_NAME() { printf 'fabio'; }
myos_default_HOST_FABIO_SERVICE_9998_AUTH() { printf 'default'; }
myos_default_HOST_FABIO_SERVICE_9998_TAGS() { myos_tagprefix HOST_FABIO 9998; }
```
Always write `${VAR:-default}` so the environment and the `.env` still win.
Two things follow, and they are the point:
- a value given anywhere (environment, `.env`, command line) wins over the
default, without the hook having to say so;
- the default is computed against the values current **at the moment it is
read**, so a `DOMAIN` set in a `.env` loaded later is taken into account.
The prefix is not decoration: a bare function named `host` or `test` would be
indistinguishable from the program of that name, and myos would run it.
Helpers available in a hook: `myos_tagprefix`, `myos_urlprefix`, `myos_uri`,
`myos_url`, `myos_envprefix`, `myos_servicenvs`, `myos_var`, `myos_lower`,
`myos_upper`.
`myos_upper`, and `myos_default NAME 'body'` when the name is built at run time.
## Templates: .env.dist
A stack may ship a `.env.dist` listing the variables it expects, with their
defaults. `myos env-update` writes the missing ones into the `.env`, expanding
`${VAR}` against the current values and running `$(command)`:
```sh
# stack/demo/.env.dist
DEMO_IMAGE=alpine:${DEMO_VERSION}
DEMO_VERSION=3.20
DEMO_SECRET=$(openssl rand -hex 16)
```
A line may refer to a variable defined further down. A variable that already
has a value keeps it: the `.env` records choices, it never overwrites them, and
running the command twice changes nothing.
## Groups
+2
View File
@@ -0,0 +1,2 @@
MYOS ?= /usr/local/lib/myos
include $(MYOS)/share/make/shim.mk
+12
View File
@@ -0,0 +1,12 @@
services:
consul:
image: hashicorp/consul:1.15
container_name: ${HOST_COMPOSE_PROJECT_NAME:-localhost}-consul
network_mode: host
restart: always
environment:
CONSUL_HTTP_TOKEN: ${HOST_CONSUL_HTTP_TOKEN}
volumes:
- consul:/consul/data
volumes:
consul:
+13
View File
@@ -0,0 +1,13 @@
services:
fabio:
image: fabiolb/fabio:1.6.3
container_name: ${HOST_COMPOSE_PROJECT_NAME:-localhost}-fabio
depends_on: [consul]
ports:
- "80:80"
- "443:443"
environment:
FABIO_REGISTRY_CONSUL_ADDR: ${DOCKER_HOST_INET4:-127.0.0.1}:8500
FABIO_LOG_ACCESS_TARGET: ${HOST_FABIO_LOG_ACCESS:-}
networks:
- public
+7
View File
@@ -0,0 +1,7 @@
host ?= host/consul host/fabio
# a target of the project, on top of the myos commands: this is what make is
# kept for, and what the shim leaves alone
.PHONY: host-certs
host-certs:
@echo "would renew the certificates of $(host)"
+9
View File
@@ -0,0 +1,9 @@
services:
registrator:
image: gliderlabs/registrator:master
container_name: ${HOST_COMPOSE_PROJECT_NAME:-localhost}-registrator
network_mode: host
depends_on: [consul]
command: -internal=false -useIpFromLabel SERVICE_ADDRESS consul://127.0.0.1:8500
volumes:
- ${DOCKER_SOCKET_LOCATION:-/var/run/docker.sock}:/tmp/docker.sock
+4
View File
@@ -77,3 +77,7 @@ cmd-scale | app-nogit | scale postgres SERVICE=postgre
cmd-ls | host-project | ls
cmd-ls-groups | host-project | ls --groups
cmd-env-domain | host-project | env DOMAIN
shim-up-group | shim-project | @make up STACK=host DRYRUN=true
shim-config | shim-project | @make config STACK=host/consul DRYRUN=true
shim-project-target | shim-project | @make host-certs
shim-env | shim-project | @make env ARGS=COMPOSE_PROJECT_NAME STACK=host
@@ -16,6 +16,7 @@ Commands:
ps logs config exec run inspect and enter them
ls [--groups] list the stacks myos can see
env [VAR...] show resolved variables
env-update fill .env from the .env.dist templates
doctor check the installation
version print the myos version
+2
View File
@@ -0,0 +1,2 @@
docker compose -f @WD@/stack/host/consul.yml -f @MYOS@/share/compose/networks.yml -p testhost --project-directory @WD@/stack/host config
[exit 0]
+2
View File
@@ -0,0 +1,2 @@
COMPOSE_PROJECT_NAME testhost
[exit 0]
@@ -0,0 +1,2 @@
would renew the certificates of host/consul host/fabio
[exit 0]
+4
View File
@@ -0,0 +1,4 @@
docker network create tester-local
docker network create testhost
docker compose -f @WD@/stack/host/consul.yml -f @WD@/stack/host/fabio.yml -f @MYOS@/share/compose/networks.yml -p testhost --project-directory @WD@/stack/host up -d
[exit 0]
+3 -2
View File
@@ -47,8 +47,9 @@ myos_normalize() {
# shellcheck disable=SC2046 # myos_hermetic_env output is meant to be word-split
myos_run_engine() {
_engine=$1; _sb=$2; shift 2
# "@make" as first arg = include mode: the project Makefile includes make/include.mk
# and make runs from the project dir (CURDIR = project). Same for both engines.
# "@make" as first arg = the project drives make itself: its Makefile includes
# either the legacy engine (make/include.mk) or the shim (make/shim.mk), and
# make runs from the project directory.
if [ "${1:-}" = "@make" ]; then
shift
_out=$(cd "$_sb/wd" && env -i $(myos_hermetic_env "$_sb") MYOS_CONF=/dev/null \
+1
View File
@@ -1,6 +1,7 @@
#shellcheck shell=sh
Include lib/str.sh
Include lib/core.sh
Include lib/var.sh
Include lib/tags.sh
Include lib/config.sh
Include lib/compose.sh
+93
View File
@@ -1,6 +1,7 @@
#shellcheck shell=sh
Include lib/str.sh
Include lib/core.sh
Include lib/var.sh
Include lib/tags.sh
Include lib/config.sh
@@ -104,3 +105,95 @@ Describe 'lib/config.sh robustness'
The status should be success
End
End
# The make engine generated a .env out of a .env.dist, expanding ${VAR} against
# the current values and running $(cmd). These check the shell equivalent.
Describe 'lib/config.sh templates'
setup() { MYOS_TMP=$(mktemp -d "${TMPDIR:-/tmp}/myos-tpl.XXXXXX"); }
cleanup() { rm -rf "$MYOS_TMP"; }
BeforeEach setup
AfterEach cleanup
Describe 'myos_expand'
It 'substitutes a variable'
DOMAIN=example.org
When call myos_expand 'https://app.${DOMAIN}/'
The output should equal "https://app.example.org/"
End
It 'substitutes several, including twice the same'
DOMAIN=example.org
When call myos_expand '${DOMAIN}:${DOMAIN}'
The output should equal "example.org:example.org"
End
It 'substitutes a lazy default like any other value'
# shellcheck disable=SC2317
myos_default_LAZY_DOMAIN() { printf 'lazy.example.org'; }
When call myos_expand 'https://${LAZY_DOMAIN}/'
The output should equal "https://lazy.example.org/"
End
It 'empties an unknown variable, as make does'
When call myos_expand 'a${NOT_SET_ANYWHERE}b'
The output should equal "ab"
End
It 'runs a command substitution'
When call myos_expand 'pre-$(echo mid)-post'
The output should equal "pre-mid-post"
End
It 'leaves a malformed reference alone'
When call myos_expand 'a${not-a-name}b'
The output should equal 'a${not-a-name}b'
End
End
Describe 'myos_env_update'
It 'adds the missing variables, expanded'
printf 'DOMAIN=example.org\nAPP_URL=https://app.${DOMAIN}/\n' > "$MYOS_TMP/.env.dist"
DOMAIN=chosen.org
When call myos_env_update "$MYOS_TMP/.env" "$MYOS_TMP/.env.dist"
The status should be success
The contents of file "$MYOS_TMP/.env" should include "APP_URL=https://app.chosen.org/"
The contents of file "$MYOS_TMP/.env" should include "DOMAIN=chosen.org"
End
It 'never touches a value already recorded'
printf 'KEEP=default\n' > "$MYOS_TMP/.env.dist"
printf 'KEEP=already-chosen\n' > "$MYOS_TMP/.env"
When call myos_env_update "$MYOS_TMP/.env" "$MYOS_TMP/.env.dist"
The contents of file "$MYOS_TMP/.env" should equal "KEEP=already-chosen"
End
It 'is idempotent'
printf 'A=1\nB=${A}2\n' > "$MYOS_TMP/.env.dist"
When run source spec/unit/config_update_helper.sh "$MYOS_TMP"
The output should equal "2"
End
It 'does nothing without a template'
When call myos_env_update "$MYOS_TMP/.env" "$MYOS_TMP/nope.dist"
The status should be success
The path "$MYOS_TMP/.env" should not be exist
End
End
End
Describe 'lib/config.sh forward references'
setup() { MYOS_TMP=$(mktemp -d "${TMPDIR:-/tmp}/myos-fwd.XXXXXX"); }
cleanup() { rm -rf "$MYOS_TMP"; }
BeforeEach setup
AfterEach cleanup
It 'resolves a reference to a variable defined further down the template'
printf 'IMAGE=alpine:${VERSION}\nVERSION=3.20\n' > "$MYOS_TMP/.env.dist"
When call myos_env_update "$MYOS_TMP/.env" "$MYOS_TMP/.env.dist"
The contents of file "$MYOS_TMP/.env" should include "IMAGE=alpine:3.20"
End
It 'resolves a chain of references'
printf 'A=${B}\nB=${C}\nC=deep\n' > "$MYOS_TMP/.env.dist"
When call myos_env_update "$MYOS_TMP/.env" "$MYOS_TMP/.env.dist"
The contents of file "$MYOS_TMP/.env" should include "A=deep"
End
It 'still lets an explicit choice win over the template'
printf 'IMAGE=alpine:${VERSION}\nVERSION=3.20\n' > "$MYOS_TMP/.env.dist"
VERSION=3.19
When call myos_env_update "$MYOS_TMP/.env" "$MYOS_TMP/.env.dist"
The contents of file "$MYOS_TMP/.env" should include "IMAGE=alpine:3.19"
The contents of file "$MYOS_TMP/.env" should include "VERSION=3.19"
End
End
+5
View File
@@ -0,0 +1,5 @@
#shellcheck shell=sh
# Running the update twice must leave the same file: print how many lines it has.
myos_env_update "$1/.env" "$1/.env.dist" >/dev/null 2>&1
myos_env_update "$1/.env" "$1/.env.dist" >/dev/null 2>&1
wc -l < "$1/.env" | tr -d ' '
+1
View File
@@ -1,6 +1,7 @@
#shellcheck shell=sh
Include lib/str.sh
Include lib/core.sh
Include lib/var.sh
Include lib/tags.sh
Include lib/naming.sh
Include lib/config.sh
+1
View File
@@ -1,6 +1,7 @@
#shellcheck shell=sh
Include lib/str.sh
Include lib/core.sh
Include lib/var.sh
Include lib/tags.sh
Include lib/stack.sh
+1
View File
@@ -1,6 +1,7 @@
#shellcheck shell=sh
Include lib/str.sh
Include lib/core.sh
Include lib/var.sh
Include lib/tags.sh
# The expectations below are the very examples left as comments in
+3
View File
@@ -0,0 +1,3 @@
#shellcheck shell=sh
myos_default BUILT 'printf "built-%s" here'
myos_var BUILT
+7
View File
@@ -0,0 +1,7 @@
#shellcheck shell=sh
# A lazy default is re-evaluated at each reference, so it follows a value that
# changes later; an explicit value still wins over it.
myos_default_LAZY_ONE() { printf 'from-%s' "$(myos_var LAZY_BASE)"; }
LAZY_BASE=first; myos_var LAZY_ONE; echo
LAZY_BASE=second; myos_var LAZY_ONE; echo
LAZY_ONE=pinned; myos_var LAZY_ONE; echo
+3
View File
@@ -0,0 +1,3 @@
#shellcheck shell=sh
myos_default_LOOPY() { myos_var LOOPY; }
myos_var LOOPY
+73
View File
@@ -0,0 +1,73 @@
#shellcheck shell=sh
Include lib/core.sh
Include lib/var.sh
Describe 'lib/var.sh'
Describe 'myos_var'
It 'returns the value of a plain variable'
SOME_VAR=plain
When call myos_var SOME_VAR
The output should equal "plain"
End
It 'returns empty for an unset variable'
When call myos_var NEVER_SET
The output should equal ""
End
It 'returns empty for an empty name'
When call myos_var ""
The output should equal ""
End
It 'never runs a command that happens to share the name'
# `host` is a real program: a lazy default must never be confused with it
When call myos_var host
The output should equal ""
The status should be success
The stderr should equal ""
End
End
Describe 'lazy defaults'
lazy() {
myos_default_LAZY_ONE() { printf 'from-%s' "$(myos_var LAZY_BASE)"; }
myos_var LAZY_ONE
}
It 'computes the default when the variable has no value'
LAZY_BASE=a
When call lazy
The output should equal "from-a"
End
It 'recomputes it against the current values'
When run source spec/unit/var_lazy_helper.sh
The line 1 should equal "from-first"
The line 2 should equal "from-second"
The line 3 should equal "pinned"
End
It 'declares a default from a string too'
When run source spec/unit/var_default_helper.sh
The output should equal "built-here"
End
It 'refuses a default written in terms of itself'
When run source spec/unit/var_loop_helper.sh
The stderr should include "defined in terms of itself"
The status should equal 1
End
End
Describe 'myos_var_is_lazy'
It 'is true for a variable that only has a default'
myos_default_ONLY_LAZY() { echo x; }
When call myos_var_is_lazy ONLY_LAZY
The status should be success
End
It 'is false once the variable has a value'
myos_default_BOTH() { echo x; }
BOTH=explicit
When call myos_var_is_lazy BOTH
The status should be failure
End
It 'is false for a program on PATH'
When call myos_var_is_lazy host
The status should be failure
End
End
End