#!/usr/bin/env bash # # Take a fresh clone to a running dev server in one command — and when it cannot, # say every reason at once instead of dying one prerequisite at a time. # # bash scripts/dev.sh # deps, database, migrations, server # bash scripts/dev.sh --check # audit prerequisites only; changes nothing # bash scripts/dev.sh --dry-run # print every command it would run # bash scripts/dev.sh --skip-deps --skip-db --skip-migrate --skip-server # DEV_DB_SERVICE=postgres bash scripts/dev.sh # # exit 0 ready exit 1 something is missing exit 2 could not check # # =========================================================================== # TEMPLATE COPY — configure this before the first run # =========================================================================== # # Copy to `scripts/dev.sh`, `chmod +x` it, and fill in the CONFIGURATION block # below. Every value there is empty on purpose and every one of them names # something that belongs to exactly one project: a compose service, a connection # variable, a migrations directory, a command line. # # `scripts/release.sh` in this same directory explains what inherited defaults # cost when they were carried between projects. The version of that script this # one is modelled on shipped with its origin project's image name, deploy host # and container name as defaults; one run in a second project would have pushed # over the first project's image and pruned its published versions. The same # shape of mistake here is quieter and still expensive: a DB_SERVICE guessed from # another project starts a container that is not this project's database, and the # first symptom is a migration running against the wrong data. # # Do NOT add `"dev": "bash scripts/dev.sh"` to package.json. This script starts # the dev server by running the `dev` script, so that entry is an infinite loop. # `"setup"` or `"bootstrap"` is the name you want. It refuses to run the loop if # you do it anyway — twice: by reading the package.json script before running it, # and by an exported marker that catches the spellings reading cannot see, such # as a `dev:` target in the Makefile that calls this file. Both are guards, not # a plan. (`DEV_SH_ALLOW_REENTRY=1` turns the second one off if you ever have a # reason to nest this inside itself deliberately.) # # Assumes: bash 4.4 or newer, git, coreutils. Everything else is detected, and # its absence is reported rather than assumed. The bash floor is checked on the # first line that can check it, because macOS still ships 3.2 and the symptom # there is an "unbound variable" from an unrelated line. # # ## Why this exists # # A README's setup section is a claim about the past, written by somebody who # already had the database running and the environment exported, and it is never # wrong for the person who wrote it. The person it is wrong for is a new clone on # an empty machine — a new hire on their first morning, or an agent with no # memory of the last checkout — which is the person with the least context to # work out what the missing piece was. # # The specific failure this ends is the bootstrap that dies one prerequisite at a # time. Install the runtime, run it again; install docker, run it again; export # the connection string, run it again. Each run teaches exactly one fact and # costs a full cycle to learn it, and none of the runs tell you how many are # left. So the audit is the product here and the four steps are the easy part: # EVERY prerequisite is checked before ANY of them runs, and they are reported # together, each with how to get it. # # ## It is the setup documentation # # The CONFIGURATION block is the list of what this project needs to run, in the # one form that cannot quietly go stale: a README paragraph that has drifted # still renders, while a wrong value here fails on the next bootstrap and names # itself. Read this file to find out what the project requires. Change this file # when the requirement changes — and if the answer is not written here, then the # project does not have one written down anywhere. # # ## Absence is not zero # # Nothing is reported as satisfied unless it was measured. A port that could not # be probed is "could not check", never "not running"; a project with no # REQUIRED_ENV is "nothing configured to check", never "environment ok". The one # that bites hardest is the TCP probe: a bash built without /dev/tcp fails # exactly like a refused connection, so a naive check sends somebody to debug a # database that was fine all along. # # ## What it deliberately does not do # # It does not create, seed, reset or drop a database, and it never runs # `docker compose down`, `rm` or `-v`. Nothing here deletes data. Stopping the # stack stays a thing you type on purpose (`docker compose stop`). # # The only thing it writes on its own is one empty stamp file under the git # directory, recording that migrations ran. Everything else that touches the # disk is the project's own installer or migration command — `npm ci` replaces # node_modules, because that is what `npm ci` does, and step 1 names the command # before running it so that is visible rather than inferred. # # It never runs `docker compose up` with no service named. That starts every # service in the file, including an app container that will take the port the dev # server is about to want, and on a shared daemon it starts things belonging to # whatever else that compose file describes. Only DB_SERVICE is ever started. # # It does not write or source `.env`. Most dev servers load their own env files, # and two loaders with different rules disagree in ways that take an afternoon to # find. It reads such a file only if you name it in ENV_FILE, and otherwise says # that it saw one and left it alone. # # It does not install tools. It names each missing one and where to get it — # installing a language runtime on somebody's behalf is how a machine ends up # with three of them and no record of which is on PATH. # # It does not run tests, build for production, or deploy. Those are separate # commands so that a broken build fails in the place that owns it. set -uo pipefail say() { printf '\033[1mdev:\033[0m %s\n' "$*" >&2; } die() { printf '\033[1mdev:\033[0m %s\n' "$*" >&2; exit 1; } # --------------------------------------------------------------------------- # The shell this needs, said before anything that depends on it. # # Before bash 4.4, `set -u` treats an empty array and a zero-length "$@" as # unbound variables. This script has both — the findings arrays, and the flag # loop on a run with no flags — so on macOS's stock bash 3.2 it would die with # "COMPOSE: unbound variable" from a line that has nothing to do with the # problem. That is exactly the one-fact-per-run failure this file exists to end, # so the version is checked first and named. # --------------------------------------------------------------------------- case "${BASH_VERSION:-}" in '') die "this needs bash, and it was started by another shell. Run: bash ${0}" ;; [0-3].*|4.[0-3]|4.[0-3].*) die "this needs bash 4.4 or newer (this is $BASH_VERSION). macOS ships 3.2: 'brew install bash', then run this with that one." ;; esac # --------------------------------------------------------------------------- # The fork bomb, guarded at the door. # # Step 4 runs whatever this project calls its dev server, and if that command # leads back here the two of them fork until the machine gives out. The # package.json check further down catches the common spelling of that mistake, # but it can only read package.json: a Makefile target, a renamed copy of this # script, or a shell wrapper all reach step 4 unseen. An exported marker catches # every one of them, at the cost of one variable. # --------------------------------------------------------------------------- if [ -n "${DEV_SH_ACTIVE:-}" ] && [ -z "${DEV_SH_ALLOW_REENTRY:-}" ]; then say "this script is already running further up this process tree, which means the" say " command it started leads back here — an infinite loop, and one that reads" say " as a hang rather than as a cycle." say " Rename whatever entry runs it (\"setup\" is the conventional name for a" say " bootstrap), or set DEV_SERVER_CMD to the command that actually starts the" say " server." die "refusing to re-enter. Set DEV_SH_ALLOW_REENTRY=1 if this nesting is deliberate." fi export DEV_SH_ACTIVE=1 # --------------------------------------------------------------------------- # Where "the project" is. # # The copy of this script lives inside the project it bootstraps. The current # directory does not, and neither does the git root: run this file by path from # inside another checkout and `git rev-parse --show-toplevel` names that other # checkout, which is how a bootstrap installs one project's dependencies into # another project's tree. So the script's own location decides, and a git root # that disagrees is said out loud rather than silently preferred. # --------------------------------------------------------------------------- SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd) [ -n "$SCRIPT_DIR" ] || die "cannot resolve the directory holding this script." # This copy's own filename. Used where the script has to talk about itself: the # usage line, and the loop guard that looks for a script entry pointing back # here — both of which are wrong the moment somebody renames the copy, which the # instructions above invite ("setup" or "bootstrap"). SELF_NAME=$(basename -- "$0") [ -n "$SELF_NAME" ] || SELF_NAME="dev.sh" ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." 2>/dev/null && pwd) [ -n "$ROOT" ] || die "cannot resolve the project root above $SCRIPT_DIR." cd "$ROOT" || die "cannot enter the project root $ROOT." GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) if [ -n "$GIT_ROOT" ] && [ "$GIT_ROOT" != "$ROOT" ]; then say "note: this script lives under $ROOT but the surrounding git checkout is" say " $GIT_ROOT. Using $ROOT — the project this copy belongs to." fi # --------------------------------------------------------------------------- # Flags # --------------------------------------------------------------------------- CHECK_ONLY="" DRY_RUN="" SKIP_DEPS="" SKIP_DB="" SKIP_MIGRATE="" SKIP_SERVER="" usage() { say "usage: bash $SELF_NAME [--check] [--dry-run]" say " [--skip-deps] [--skip-db] [--skip-migrate] [--skip-server]" say "exit: 0 ready · 1 something is missing · 2 something could not be checked" } for arg in "$@"; do case "$arg" in --check) CHECK_ONLY="yes" ;; --dry-run) DRY_RUN="yes" ;; --skip-deps) SKIP_DEPS="yes" ;; --skip-db) SKIP_DB="yes" ;; --skip-migrate) SKIP_MIGRATE="yes" ;; --skip-server) SKIP_SERVER="yes" ;; -h|--help) usage; exit 0 ;; *) usage; die "unknown argument '$arg'." ;; esac done # --------------------------------------------------------------------------- # CONFIGURATION — fill these in, then delete this banner. # # Empty means "not configured", which is never treated as "not needed": where # the difference matters there is an explicit way to say "this project has none". # Each may be overridden for one run by the DEV_* variable named beside it. # --------------------------------------------------------------------------- # The compose service that IS this project's database. Set it to the literal # string "none" if this project has no database — an empty value means nobody # has decided yet, and this script will not guess which of your services holds # your data. DB_SERVICE="${DEV_DB_SERVICE:-}" # Compose file. Empty means: look for compose.yaml / compose.yml / # docker-compose.yml / docker-compose.yaml in the project root. COMPOSE_FILE="${DEV_COMPOSE_FILE:-}" # The NAME of the environment variable holding the database connection URL — # e.g. DATABASE_URL, not the URL itself. Its value is read to find the host and # port to probe, and this file never needs to contain a credential. DB_URL_ENV="${DEV_DB_URL_ENV:-}" # Environment variables without which this project does not run. Listing them is # the whole point of the audit: an unset one is worth more as a line in the # missing-prerequisites report than as a stack trace forty seconds into a boot. REQUIRED_ENV=() # A file to source before checking (with `set -a`). Leave empty unless the # project genuinely expects a shell to load it — see the header on why sourcing # .env behind the dev server's back is usually the wrong answer. ENV_FILE="${DEV_ENV_FILE:-}" # Command lines, run with `bash -c`. Leave empty to have them detected from the # lockfiles, package.json scripts and Makefile targets that are actually here; # set one when detection is wrong or when the project's stack is not one of the # four this knows. Detection never invents a name — it reads them. # # These strings are PRINTED: in the plan, by --dry-run, and again as each one # runs. Anything inline in them ends up in whatever captured that output — a CI # log, a scrollback, a pasted bug report. Put credentials in an environment # variable the command reads, never in the command line itself. DEPS_CMD="${DEV_DEPS_CMD:-}" MIGRATE_CMD="${DEV_MIGRATE_CMD:-}" SERVER_CMD="${DEV_SERVER_CMD:-}" # Directory holding migration files. Only used to skip a migrate run when # nothing in it has changed since the last successful one. Empty is fine and # means migrations run every time. MIGRATIONS_DIR="${DEV_MIGRATIONS_DIR:-}" # Seconds to wait for the database port to answer after starting it. DB_WAIT="${DEV_DB_WAIT:-60}" # --------------------------------------------------------------------------- case "$DB_WAIT" in ''|*[!0-9]*) die "DEV_DB_WAIT must be a whole number of seconds, got '$DB_WAIT'." ;; esac if [ -n "${DEV_REQUIRED_ENV:-}" ]; then # Word-split on purpose: DEV_REQUIRED_ENV is a space-separated list of names. # shellcheck disable=SC2206 REQUIRED_ENV+=($DEV_REQUIRED_ENV) fi if [ -n "$ENV_FILE" ]; then [ -r "$ENV_FILE" ] || die "ENV_FILE '$ENV_FILE' is set but not readable. Fix the path or clear it." set -a # shellcheck disable=SC1090 . "$ENV_FILE" || die "sourcing '$ENV_FILE' failed — nothing was started." set +a say "sourced $ENV_FILE" fi # --------------------------------------------------------------------------- # Findings. Collected, never printed as they are found, because the entire value # of this script is that you learn all of them in one run. # # problem — a step cannot run. Fixable, and the fix is stated. # unknown — could not be determined. Not a pass and not a failure. # note — true and worth knowing; blocks nothing. # --------------------------------------------------------------------------- PROBLEMS=() UNKNOWNS=() NOTES=() SKIPPED=() problem() { PROBLEMS+=("$1"$'\t'"$2"$'\t'"$3"); } unknown() { UNKNOWNS+=("$1"$'\t'"$2"); } note() { NOTES+=("$1"); } skipped() { SKIPPED+=("$1"$'\t'"$2"); } have() { command -v "$1" >/dev/null 2>&1; } # Free-standing so a tool needed by two steps is demanded once, with one fix. need_tool() { local tool=$1 why=$2 how=$3 have "$tool" && return 0 problem "$tool" "$why" "$how" return 1 } has_word() { case " $2 " in *" $1 "*) return 0 ;; esac return 1 } # Whole-line membership in newline-separated output, plus the same output on one # line for a message. Both exist because `docker compose ps --services` prints a # single empty line when nothing is running: joined into a string that is a lone # space, which is not empty, which made "nothing is running" indistinguishable # from "the list could not be read". Emptiness never decides anything here — the # exit status of the command that produced the list does. # # A here-string rather than a pipe into `grep -q`: grep exits at the first match, # and under `pipefail` the SIGPIPE that kills the writer becomes the pipeline's # status — turning a found line into "not found" for a list long enough to fill # a pipe buffer. The answer would be "no such service in your compose file" # about a service that is right there. has_line() { grep -qxF -- "$2" <<<"$1"; } one_line() { printf '%s' "$1" | tr '\n' ' ' | sed 's/ */ /g; s/^ //; s/ $//'; } # --------------------------------------------------------------------------- # Detect the stack. Four markers, each read from the filesystem, none assumed. # --------------------------------------------------------------------------- STACKS="" MAKEFILE="" [ -f package.json ] && STACKS="$STACKS node" [ -f Cargo.toml ] && STACKS="$STACKS rust" [ -f pyproject.toml ] && STACKS="$STACKS python" if [ -f Makefile ]; then MAKEFILE="Makefile"; elif [ -f makefile ]; then MAKEFILE="makefile"; fi [ -n "$MAKEFILE" ] && STACKS="$STACKS make" STACKS="${STACKS# }" if [ -z "$STACKS" ] && [ -z "$DEPS_CMD$SERVER_CMD" ]; then say "no package.json, Cargo.toml, pyproject.toml or Makefile in $ROOT." say " Either this is not the project root, or the stack is one this script" say " cannot detect." die "set DEV_DEPS_CMD and DEV_SERVER_CMD (or the CONFIGURATION block) to teach it." fi # package.json is JSON and this script has no JSON parser — deliberately, because # the alternative is a regex that is wrong for exactly the files that matter. So # node reads it, and when node is missing the scripts are reported as UNREADABLE # rather than guessed at. That is honest: without node those scripts cannot be # run either. PKG_SCRIPTS="" PKG_SCRIPTS_READ="" PKG_SCRIPTS_WHY="" if has_word node "$STACKS"; then if have node; then # An empty scripts block and an unparseable package.json are different answers, # so the read is what sets the flag, not the emptiness of the result. if PKG_SCRIPTS=$(node -e 'const s=require("./package.json").scripts||{};process.stdout.write(Object.keys(s).join(" "))' 2>/dev/null); then PKG_SCRIPTS_READ="yes" else # Why it could not be read is a separate fact from that it could not be # read, and there are two reasons. Reporting the wrong one sends somebody # to install a runtime they already have while a trailing comma in their # package.json goes unmentioned. PKG_SCRIPTS_WHY="node is installed but could not parse ./package.json — check it is valid JSON: node -e 'require(\"./package.json\")'" fi else PKG_SCRIPTS_WHY="node is not on PATH" fi fi pkg_script_body() { PKG_SCRIPT_NAME="$1" node -e 'const s=require("./package.json").scripts||{};process.stdout.write(s[process.env.PKG_SCRIPT_NAME]||"")' 2>/dev/null } MAKE_TARGETS="" if [ -n "$MAKEFILE" ]; then # Plain grep rather than `make -qp`, which is the accurate way to list targets # and evaluates the makefile to do it — running every `$(shell …)` in it before # this script has checked a single prerequisite. Reading the file is worth the # occasional missed target; a bootstrap that executes something during its own # audit is not. # # The second grep drops `VAR:=x` and `VAR::=x`, which the first one reads as a # target called VAR. That matters here because these names are not just # printed: a variable named `dev` would become "make dev" and be run as this # project's dev server. MAKE_TARGETS=$(grep -hE '^[a-zA-Z0-9][a-zA-Z0-9_.-]*:' "$MAKEFILE" 2>/dev/null \ | grep -vE '^[a-zA-Z0-9][a-zA-Z0-9_.-]*::?=' \ | sed 's/:.*//' | sort -u | tr '\n' ' ') MAKE_TARGETS="${MAKE_TARGETS% }" fi # --------------------------------------------------------------------------- # Plan: work out the command for each step before running any of them, so the # audit knows which tools actually matter. A missing tool for a step that will # not run is not a missing prerequisite. # --------------------------------------------------------------------------- DEPS_ARGV=() DEPS_DESC="" DEPS_WHY="" DEPS_FRESH_IF="" # file whose mtime, if older than the install marker, means skip DEPS_MARKER="" plan_deps() { if [ -n "$DEPS_CMD" ]; then DEPS_ARGV=(bash -c "$DEPS_CMD") DEPS_DESC="$DEPS_CMD" DEPS_WHY="DEPS_CMD is configured" return 0 fi if has_word node "$STACKS"; then DEPS_MARKER="node_modules" if [ -f package-lock.json ]; then DEPS_ARGV=(npm ci); DEPS_DESC="npm ci"; DEPS_FRESH_IF="package-lock.json" DEPS_WHY="package-lock.json is here" elif [ -f pnpm-lock.yaml ]; then DEPS_ARGV=(pnpm install); DEPS_DESC="pnpm install"; DEPS_FRESH_IF="pnpm-lock.yaml" DEPS_WHY="pnpm-lock.yaml is here" elif [ -f yarn.lock ]; then DEPS_ARGV=(yarn install); DEPS_DESC="yarn install"; DEPS_FRESH_IF="yarn.lock" DEPS_WHY="yarn.lock is here" elif [ -f bun.lockb ] || [ -f bun.lock ]; then DEPS_ARGV=(bun install); DEPS_DESC="bun install" if [ -f bun.lockb ]; then DEPS_FRESH_IF="bun.lockb"; else DEPS_FRESH_IF="bun.lock"; fi DEPS_WHY="a bun lockfile is here" else DEPS_ARGV=(npm install); DEPS_DESC="npm install"; DEPS_FRESH_IF="package.json" DEPS_WHY="package.json is here and no lockfile is" fi return 0 fi if has_word python "$STACKS"; then if [ -f uv.lock ]; then DEPS_ARGV=(uv sync); DEPS_DESC="uv sync"; DEPS_MARKER=".venv"; DEPS_FRESH_IF="uv.lock" DEPS_WHY="uv.lock is here" elif [ -f poetry.lock ]; then DEPS_ARGV=(poetry install); DEPS_DESC="poetry install"; DEPS_MARKER=".venv"; DEPS_FRESH_IF="poetry.lock" DEPS_WHY="poetry.lock is here" elif [ -f requirements.txt ]; then DEPS_ARGV=(bash -c 'python3 -m venv .venv && .venv/bin/pip install -r requirements.txt') DEPS_DESC="python3 -m venv .venv && .venv/bin/pip install -r requirements.txt" DEPS_MARKER=".venv"; DEPS_FRESH_IF="requirements.txt" DEPS_WHY="requirements.txt is here" fi [ -n "$DEPS_DESC" ] && return 0 fi if has_word rust "$STACKS"; then # No freshness check: `cargo fetch` is already a no-op when the lockfile is # satisfied, and inventing a marker file to guess that would be a second, # worse copy of state cargo already keeps correctly. DEPS_ARGV=(cargo fetch); DEPS_DESC="cargo fetch"; DEPS_WHY="Cargo.toml is here" return 0 fi if [ -n "$MAKE_TARGETS" ]; then local target for target in deps setup install bootstrap; do if has_word "$target" "$MAKE_TARGETS"; then DEPS_ARGV=(make "$target"); DEPS_DESC="make $target" DEPS_WHY="$MAKEFILE has a '$target' target" return 0 fi done fi return 1 } MIGRATE_ARGV=() MIGRATE_DESC="" MIGRATE_WHY="" MIGRATE_LOOKED="" plan_migrate() { if [ -n "$MIGRATE_CMD" ]; then MIGRATE_ARGV=(bash -c "$MIGRATE_CMD"); MIGRATE_DESC="$MIGRATE_CMD" MIGRATE_WHY="MIGRATE_CMD is configured" return 0 fi local name if [ -n "$PKG_SCRIPTS_READ" ]; then for name in migrate db:migrate migrate:dev db:push; do MIGRATE_LOOKED="$MIGRATE_LOOKED package.json:$name" if has_word "$name" "$PKG_SCRIPTS"; then MIGRATE_ARGV=(npm run "$name"); MIGRATE_DESC="npm run $name" MIGRATE_WHY="package.json defines a '$name' script" return 0 fi done fi if [ -n "$MAKE_TARGETS" ]; then for name in migrate db-migrate db.migrate; do MIGRATE_LOOKED="$MIGRATE_LOOKED $MAKEFILE:$name" if has_word "$name" "$MAKE_TARGETS"; then MIGRATE_ARGV=(make "$name"); MIGRATE_DESC="make $name" MIGRATE_WHY="$MAKEFILE has a '$name' target" return 0 fi done fi MIGRATE_LOOKED="${MIGRATE_LOOKED# }" return 1 } SERVER_ARGV=() SERVER_DESC="" SERVER_WHY="" SERVER_LOOKED="" SERVER_LOOP="" plan_server() { if [ -n "$SERVER_CMD" ]; then SERVER_ARGV=(bash -c "$SERVER_CMD"); SERVER_DESC="$SERVER_CMD" SERVER_WHY="SERVER_CMD is configured" return 0 fi local name body if [ -n "$PKG_SCRIPTS_READ" ]; then for name in dev start serve; do SERVER_LOOKED="$SERVER_LOOKED package.json:$name" has_word "$name" "$PKG_SCRIPTS" || continue # The loop this template invites: somebody adds "dev": "bash scripts/dev.sh" # to package.json, and step 4 then runs the bootstrap that runs step 4. It # forks until something on the machine gives out, and the output is four # identical banners a second, which reads as a hang rather than as a cycle. # # Matched against this copy's own filename as well as the template's, # because the instructions at the top invite renaming it and a guard that # only knows the name it used to have is not a guard. What this still # cannot see — a Makefile target, a wrapper script — is caught at startup # by the DEV_SH_ACTIVE marker instead. body=$(pkg_script_body "$name") case "$body" in *"$SELF_NAME"*|*dev.sh*) SERVER_LOOP="yes" problem "package.json's '$name' script" \ "it runs '$body', which re-enters this script — an infinite loop, and one that reads as a hang rather than as a cycle" \ "rename it (\"setup\" is the conventional name for a bootstrap), or set DEV_SERVER_CMD to the command that actually starts the server" return 1 ;; esac SERVER_ARGV=(npm run "$name"); SERVER_DESC="npm run $name" SERVER_WHY="package.json defines a '$name' script" return 0 done fi if [ -n "$MAKE_TARGETS" ]; then for name in dev run serve start; do SERVER_LOOKED="$SERVER_LOOKED $MAKEFILE:$name" if has_word "$name" "$MAKE_TARGETS"; then SERVER_ARGV=(make "$name"); SERVER_DESC="make $name" SERVER_WHY="$MAKEFILE has a '$name' target" return 0 fi done fi if has_word rust "$STACKS"; then SERVER_LOOKED="$SERVER_LOOKED Cargo.toml" SERVER_ARGV=(cargo run); SERVER_DESC="cargo run"; SERVER_WHY="Cargo.toml is here" return 0 fi SERVER_LOOKED="${SERVER_LOOKED# }" return 1 } HAVE_DEPS=""; plan_deps && HAVE_DEPS="yes" HAVE_MIGRATE=""; plan_migrate && HAVE_MIGRATE="yes" HAVE_SERVER=""; plan_server && HAVE_SERVER="yes" # --------------------------------------------------------------------------- # Compose and the database. # --------------------------------------------------------------------------- if [ -z "$COMPOSE_FILE" ]; then for candidate in compose.yaml compose.yml docker-compose.yml docker-compose.yaml; do if [ -f "$candidate" ]; then COMPOSE_FILE="$candidate"; break; fi done elif [ ! -f "$COMPOSE_FILE" ]; then die "COMPOSE_FILE '$COMPOSE_FILE' does not exist. Fix the path or clear it to autodetect." fi COMPOSE=() if have docker && docker compose version >/dev/null 2>&1; then COMPOSE=(docker compose) elif have docker-compose; then COMPOSE=(docker-compose) fi compose_services() { [ ${#COMPOSE[@]} -gt 0 ] || return 1 "${COMPOSE[@]}" -f "$COMPOSE_FILE" config --services 2>/dev/null } compose_running() { [ ${#COMPOSE[@]} -gt 0 ] || return 1 # Two spellings across compose generations. Neither is asked to be present: # a failure here only costs the "already running" message, because `up -d` on # a running service is a no-op anyway. "${COMPOSE[@]}" -f "$COMPOSE_FILE" ps --services --status running 2>/dev/null \ || "${COMPOSE[@]}" -f "$COMPOSE_FILE" ps --services --filter status=running 2>/dev/null } # 0 open · 1 closed · 2 could not determine. # # The last one exists because of a specific trap: a bash built without network # redirections reports /dev/tcp as a missing file, which is indistinguishable # from a refused connection unless the message is read. Reporting that as "the # database is down" sends somebody to debug a database that is fine. probe_tcp() { local host=$1 port=$2 out rc if have nc; then # &1 /dev/tcp/"$1"/"$2"' dev-probe "$host" "$port" 2>&1); rc=$? # The status decides, not the emptiness of the output: a connection killed by # `timeout` exits 124 having printed nothing at all, which is the same output # a successful connection produces. [ "$rc" -eq 0 ] && return 0 [ "$rc" -eq 124 ] && return 1 case "$out" in *"Connection refused"*|*"onnection timed out"*|*"No route to host"*|\ *"Name or service not known"*|*"nodename nor servname"*) return 1 ;; *) return 2 ;; esac fi # Without `timeout`, a /dev/tcp attempt against a filtered host hangs for the # kernel's connect timeout, and a bootstrap that appears to freeze teaches less # than one that says it could not look. return 2 } DB_HOST="" DB_PORT="" DB_ENDPOINT_WHY="" plan_db_endpoint() { [ -n "$DB_URL_ENV" ] || { DB_ENDPOINT_WHY="DB_URL_ENV is not configured"; return 1; } # DB_URL_ENV holds a variable NAME. Put the URL there instead — the mistake the # CONFIGURATION comment above warns about — and the indirect expansion below is # bash's to complain about, not this script's: it prints `: # invalid variable name` on stderr, and that value is a connection string with # a password in it. So the name is checked first, and nothing about the value # is ever echoed. case "$DB_URL_ENV" in *[!A-Za-z0-9_]*|[!A-Za-z_]*) DB_ENDPOINT_WHY="DB_URL_ENV must be the NAME of a variable (DATABASE_URL), not its value. What it holds is not shown here, because a connection URL usually contains a password" return 1 ;; esac local url=${!DB_URL_ENV-} if [ -z "$url" ]; then DB_ENDPOINT_WHY="\$$DB_URL_ENV is unset" return 1 fi case "$url" in *://*) : ;; *) DB_ENDPOINT_WHY="\$$DB_URL_ENV is not a URL" ; return 1 ;; esac local scheme rest hostport pathish scheme=${url%%://*} rest=${url#*://} hostport=${rest%%/*} hostport=${hostport%%\?*} hostport=${hostport%%#*} case "$hostport" in *@*) hostport=${hostport##*@} ;; # credentials, if any *) # No '@' before the first '/' — but if there is one after it, the cut above # landed INSIDE the credentials, which is what an unencoded '/' in a # password does. Base64 passwords are full of them. Left alone, every # character now in $hostport is password: it gets printed in the report, # and `appuser:99` out of `appuser:99/xY9zQ@dbhost` even parses as a # host and a port, so the script resolves it — putting a fragment of the # password into DNS. Neither probed nor printed. pathish=${rest#*/} pathish=${pathish%%\?*} pathish=${pathish%%#*} case "$pathish" in *@*) DB_ENDPOINT_WHY="\$$DB_URL_ENV could not be parsed: an '@' appears after the first '/', so where the credentials end cannot be known without guessing. Percent-encode any '/' in the password (%2F). Nothing from the URL is printed here, in case that guess would have been part of it" return 1 ;; esac ;; esac case "$hostport" in \[*\]*) # IPv6 literal DB_HOST=${hostport#\[}; DB_HOST=${DB_HOST%%\]*} case "$hostport" in *\]:*) DB_PORT=${hostport##*\]:} ;; esac ;; *:*) DB_HOST=${hostport%%:*}; DB_PORT=${hostport##*:} ;; *) DB_HOST=$hostport ;; esac if [ -z "$DB_HOST" ]; then # A unix-socket URL has no host, and probing a TCP port for one would be an # answer about something that is not this connection. DB_ENDPOINT_WHY="\$$DB_URL_ENV names no TCP host (a socket connection?)" return 1 fi # What a hostname or an IP literal can contain, and nothing else. This is the # last gate before $DB_HOST becomes an argument to a network probe, and a host # holding characters that cannot be in one means the parse went somewhere it # should not have — most likely into the credentials. Refused, and not echoed. case "$DB_HOST" in *[!A-Za-z0-9.:_-]*) DB_HOST=""; DB_PORT="" DB_ENDPOINT_WHY="\$$DB_URL_ENV did not yield a usable hostname — what was found contains characters a host cannot. It is not printed here, in case it is part of a password" return 1 ;; esac if [ -z "$DB_PORT" ]; then case "$scheme" in postgres|postgresql) DB_PORT=5432 ;; mysql|mariadb) DB_PORT=3306 ;; redis|rediss) DB_PORT=6379 ;; mongodb|mongodb+srv) DB_PORT=27017 ;; *) DB_ENDPOINT_WHY="\$$DB_URL_ENV has no port and '$scheme' has no port this script knows" return 1 ;; esac fi # Not quoted into the message: the commonest way this fails is a password with # an unencoded ':' or '/' in it, which puts a piece of that password here. case "$DB_PORT" in ''|*[!0-9]*) DB_HOST=""; DB_PORT="" DB_ENDPOINT_WHY="\$$DB_URL_ENV did not yield a numeric port. If the password contains an unencoded ':' or '/', percent-encode it. The value found is not printed here, in case it is part of that password" return 1 ;; esac return 0 } HAVE_DB_ENDPOINT="" plan_db_endpoint && HAVE_DB_ENDPOINT="yes" # An unset DB_SERVICE is not a database this script may start. It is a question # nobody has answered yet, and `up -d ""` would be this script guessing. WANT_DB="" if [ -z "$SKIP_DB" ] && [ -n "$DB_SERVICE" ] && [ "$DB_SERVICE" != "none" ] && [ -n "$COMPOSE_FILE" ]; then WANT_DB="yes" fi # --------------------------------------------------------------------------- # The audit. Everything, before anything. # --------------------------------------------------------------------------- audit() { have git || note "git is not on PATH. Nothing here needs it, but the project's own tooling probably does." # --- step 1 tools ------------------------------------------------------- if [ -z "$SKIP_DEPS" ]; then if [ -n "$HAVE_DEPS" ]; then case "${DEPS_ARGV[0]}" in npm|npx) need_tool npm "installing dependencies ($DEPS_DESC)" "Node.js — https://nodejs.org, or nvm/fnm/asdf" ;; pnpm) need_tool pnpm "installing dependencies ($DEPS_DESC)" "corepack enable pnpm — https://pnpm.io/installation" ;; yarn) need_tool yarn "installing dependencies ($DEPS_DESC)" "corepack enable yarn — https://yarnpkg.com/getting-started/install" ;; bun) need_tool bun "installing dependencies ($DEPS_DESC)" "https://bun.sh" ;; cargo) need_tool cargo "fetching crates ($DEPS_DESC)" "https://rustup.rs" ;; uv) need_tool uv "installing dependencies ($DEPS_DESC)" "https://docs.astral.sh/uv/getting-started/installation/" ;; poetry) need_tool poetry "installing dependencies ($DEPS_DESC)" "https://python-poetry.org/docs/#installation" ;; make) need_tool make "installing dependencies ($DEPS_DESC)" "build-essential (apt) or the Xcode command line tools" ;; bash) : ;; esac # node is what runs npm's scripts, and a package.json project needs it even # when the package manager is not npm. if has_word node "$STACKS"; then need_tool node "running this project at all (package.json is here)" "Node.js — https://nodejs.org, or nvm/fnm/asdf" fi if has_word python "$STACKS" && [ "${DEPS_ARGV[0]}" = "bash" ]; then need_tool python3 "creating .venv ($DEPS_DESC)" "your distribution's python3 package, or pyenv" fi else note "no dependency install step found for this project — skipping step 1. Set DEV_DEPS_CMD if there is one." fi fi # --- step 2: database --------------------------------------------------- if [ -z "$SKIP_DB" ]; then if [ "$DB_SERVICE" = "none" ]; then note "DB_SERVICE is \"none\" — this project is declared to have no database." elif [ -z "$DB_SERVICE" ] && [ -n "$COMPOSE_FILE" ]; then local services="" services=$(one_line "$(compose_services)") problem "DB_SERVICE" \ "$COMPOSE_FILE is here but nothing says which of its services is the database${services:+ (services: $services)}" \ "set DB_SERVICE (or DEV_DB_SERVICE) to that service, or to \"none\" if this project has no database" elif [ -z "$DB_SERVICE" ]; then note "no compose file here and DB_SERVICE is unset — step 2 will do nothing. Set DB_SERVICE=\"none\" to record that on purpose." elif [ -z "$COMPOSE_FILE" ]; then problem "a compose file" \ "DB_SERVICE is '$DB_SERVICE' but there is no compose file to start it from" \ "add one, set DEV_COMPOSE_FILE, or set DB_SERVICE=\"none\" if the database runs elsewhere" else if need_tool docker "starting the '$DB_SERVICE' database service" "https://docs.docker.com/engine/install/"; then if [ ${#COMPOSE[@]} -eq 0 ]; then problem "docker compose" \ "docker is installed but neither 'docker compose' nor 'docker-compose' works" \ "install the Compose plugin — https://docs.docker.com/compose/install/" else local services if services=$(compose_services); then if ! has_line "$services" "$DB_SERVICE"; then problem "$DB_SERVICE" \ "no such service in $COMPOSE_FILE (it defines: $(one_line "$services"))" \ "set DB_SERVICE to one of those, or to \"none\"" fi else # A blocker rather than a note, because this step is going to run: an # unparseable compose file and a stopped daemon look identical from # here, and neither makes `up $DB_SERVICE` mean what it should. problem "$COMPOSE_FILE" \ "its services could not be listed, so nothing can confirm that '$DB_SERVICE' is in it" \ "run '${COMPOSE[*]} -f $COMPOSE_FILE config --services' to see why — usually the docker daemon is not running" fi fi fi fi fi # --- step 3 tools ------------------------------------------------------- if [ -z "$SKIP_MIGRATE" ]; then if [ -n "$HAVE_MIGRATE" ]; then case "${MIGRATE_ARGV[0]}" in npm) need_tool npm "running migrations ($MIGRATE_DESC)" "Node.js — https://nodejs.org" ;; make) need_tool make "running migrations ($MIGRATE_DESC)" "build-essential (apt) or the Xcode command line tools" ;; esac else note "no migrate step found — looked for ${MIGRATE_LOOKED:-nothing, because neither package.json nor a Makefile could be read}. Set DEV_MIGRATE_CMD if this project has one." fi fi # --- step 4 tools ------------------------------------------------------- if [ -z "$SKIP_SERVER" ]; then if [ -n "$HAVE_SERVER" ]; then case "${SERVER_ARGV[0]}" in npm) need_tool npm "starting the dev server ($SERVER_DESC)" "Node.js — https://nodejs.org" ;; make) need_tool make "starting the dev server ($SERVER_DESC)" "build-essential (apt) or the Xcode command line tools" ;; cargo) need_tool cargo "starting the dev server ($SERVER_DESC)" "https://rustup.rs" ;; esac elif [ -z "$SERVER_LOOP" ]; then # Not raised when the loop guard already fired: that is the same fault, and # a report that says one problem twice is a report you learn to skim. problem "a way to start this project" \ "nothing was found. Looked for: ${SERVER_LOOKED:-Makefile targets, Cargo.toml${PKG_SCRIPTS_WHY:+, and package.json scripts — which could not be read: $PKG_SCRIPTS_WHY}}" \ "set DEV_SERVER_CMD (or SERVER_CMD in the CONFIGURATION block) to the command that starts it" fi if has_word node "$STACKS" && [ -z "$PKG_SCRIPTS_READ" ]; then unknown "package.json" "its scripts could not be read: ${PKG_SCRIPTS_WHY:-no reason was recorded} — fix that and run --check again to find out what this project actually defines" fi fi # --- environment -------------------------------------------------------- local seen_env_file="" if [ -n "$ENV_FILE" ]; then seen_env_file="$ENV_FILE" elif [ -f .env ]; then seen_env_file=".env" note ".env is here and this script did not source it (ENV_FILE is unset). Most dev servers load it themselves; see the header." fi if [ ${#REQUIRED_ENV[@]} -eq 0 ]; then note "REQUIRED_ENV is empty — no environment variables were checked, which is not the same as none being needed. List them to make this audit worth running." else local name value for name in ${REQUIRED_ENV[@]+"${REQUIRED_ENV[@]}"}; do value=${!name-} [ -n "$value" ] && continue if [ -n "$seen_env_file" ] && grep -qE "^[[:space:]]*(export[[:space:]]+)?$name=" "$seen_env_file" 2>/dev/null; then note "\$$name is unset in this shell but present in $seen_env_file. Fine if the dev server loads that file; not fine if it does not." else problem "\$$name" "listed in REQUIRED_ENV and unset" "export it, or add it to the env file this project loads" fi done fi # --- database reachability --------------------------------------------- # # Only meaningful once there is somewhere to probe. When step 2 is going to # start the database, an unreachable port is expected rather than wrong — the # audit says so instead of reporting a failure it is about to fix. if [ -n "$HAVE_DB_ENDPOINT" ]; then probe_tcp "$DB_HOST" "$DB_PORT" case $? in 0) note "$DB_HOST:$DB_PORT answers (a port that answers, not a database that is ready — the migrate step is what proves that)." ;; 1) if [ -n "$WANT_DB" ]; then note "$DB_HOST:$DB_PORT is not answering yet — step 2 starts '$DB_SERVICE' and waits up to ${DB_WAIT}s for it." else problem "$DB_HOST:$DB_PORT" \ "nothing is listening there, and no compose service is configured to start it" \ "start the database yourself, or set DB_SERVICE so this script can" fi ;; *) unknown "$DB_HOST:$DB_PORT" "could not be probed: no nc that answered and no usable bash /dev/tcp — install netcat-openbsd (busybox's nc has no -z and cannot answer this) for a definite one" ;; esac else note "database reachability was NOT checked: ${DB_ENDPOINT_WHY:-no endpoint could be worked out}." fi } audit # --------------------------------------------------------------------------- # Report. All of it, once. # --------------------------------------------------------------------------- # What each step will do, said before it is done — so --check and a real run # print the same four lines and a wrong plan is caught by reading rather than by # watching it happen. plan_line() { local number=$1 name=$2 skip_flag=$3 flag_name=$4 what=$5 if [ -n "$skip_flag" ]; then say " $number $name SKIPPED ($flag_name)" else say " $number $name ${what:-none found}" fi } db_plan="" if [ "$DB_SERVICE" = "none" ]; then db_plan="none — DB_SERVICE is \"none\"" elif [ -z "$DB_SERVICE" ]; then db_plan="undecided — DB_SERVICE is unset" elif [ -n "$WANT_DB" ] && [ ${#COMPOSE[@]} -gt 0 ]; then db_plan="${COMPOSE[*]} -f $COMPOSE_FILE up -d $DB_SERVICE" elif [ -n "$WANT_DB" ]; then db_plan="start '$DB_SERVICE' from $COMPOSE_FILE" fi say "project: $ROOT" say "stack: ${STACKS:-none detected}${MAKE_TARGETS:+ ($MAKEFILE targets: $MAKE_TARGETS)}" if has_word node "$STACKS"; then if [ -n "$PKG_SCRIPTS_READ" ]; then say "scripts: ${PKG_SCRIPTS:-package.json defines none}" else say "scripts: package.json could not be read — ${PKG_SCRIPTS_WHY:-no reason was recorded}" fi fi say "plan:" plan_line 1 "deps " "$SKIP_DEPS" "--skip-deps" "$DEPS_DESC" plan_line 2 "database" "$SKIP_DB" "--skip-db" "$db_plan" plan_line 3 "migrate " "$SKIP_MIGRATE" "--skip-migrate" "$MIGRATE_DESC" plan_line 4 "server " "$SKIP_SERVER" "--skip-server" "$SERVER_DESC" if [ ${#NOTES[@]} -gt 0 ]; then say "" for entry in ${NOTES[@]+"${NOTES[@]}"}; do say "note: $entry" done fi if [ ${#UNKNOWNS[@]} -gt 0 ]; then say "" say "COULD NOT CHECK (${#UNKNOWNS[@]}) — not the same as ok:" for entry in ${UNKNOWNS[@]+"${UNKNOWNS[@]}"}; do IFS=$'\t' read -r what why <<<"$entry" say " $what" say " $why" done fi if [ ${#PROBLEMS[@]} -gt 0 ]; then say "" say "MISSING (${#PROBLEMS[@]}). All of them, so that fixing them is one pass:" for entry in ${PROBLEMS[@]+"${PROBLEMS[@]}"}; do IFS=$'\t' read -r what why how <<<"$entry" say " $what" say " needed for: $why" say " fix: $how" done say "" die "nothing was started. Fix the ${#PROBLEMS[@]} above and run this again." fi if [ -n "$CHECK_ONLY" ]; then if [ ${#UNKNOWNS[@]} -gt 0 ]; then say "--check: no missing prerequisites, but ${#UNKNOWNS[@]} could not be checked." exit 2 fi say "--check: everything this script knows to check is in place. Nothing was changed." exit 0 fi [ -n "$DRY_RUN" ] && say "--dry-run: nothing below is executed." # `run` is the single place a side effect happens, which is what makes --dry-run # a property of the script rather than a promise repeated at four call sites. run() { # An empty argv is not a command that succeeded. `"$@"` with nothing in it # runs nothing and returns 0, so a step whose plan came out empty would report # itself done — the loudest possible version of absence dressed as a result. [ "$#" -gt 0 ] || die "internal error: a step reached the point of running with an empty command line. Nothing was run; this is a bug in this script, not in your project." if [ -n "$DRY_RUN" ]; then say " would run: $*" return 0 fi say " $*" "$@" } # --------------------------------------------------------------------------- # Step 1 — dependencies # --------------------------------------------------------------------------- say "" say "step 1/4: dependencies" if [ -n "$SKIP_DEPS" ]; then say " skipped (--skip-deps)." skipped "deps" "--skip-deps" elif [ -z "$HAVE_DEPS" ]; then say " nothing to do — no install step was found for this project." skipped "deps" "no install step found" elif [ -n "$DEPS_MARKER" ] && [ -n "$DEPS_FRESH_IF" ] && \ [ -e "$DEPS_MARKER" ] && [ "$DEPS_MARKER" -nt "$DEPS_FRESH_IF" ]; then # mtime, not a hash: the question is only "was this installed after the # manifest last changed", and the answer being conservative in the wrong # direction costs one redundant install rather than a wrong tree. say " skipped — $DEPS_MARKER is newer than $DEPS_FRESH_IF, so it is assumed current." say " That is an mtime, not a verified install: an interrupted one leaves" say " a directory that is both newer and incomplete." say " Delete $DEPS_MARKER to force a reinstall." skipped "deps" "$DEPS_MARKER newer than $DEPS_FRESH_IF" else say " $DEPS_DESC ($DEPS_WHY)" if ! run "${DEPS_ARGV[@]}"; then die "dependency install failed. Nothing was started; fix the error above and run this again." fi fi # --------------------------------------------------------------------------- # Step 2 — database # # Only the one named service is ever touched, and it is only ever started. See # the header for why `up` with no argument and `down` are both absent. # --------------------------------------------------------------------------- say "" say "step 2/4: database" if [ -n "$SKIP_DB" ]; then say " skipped (--skip-db)." skipped "database" "--skip-db" elif [ "$DB_SERVICE" = "none" ]; then say " nothing to do — DB_SERVICE is \"none\"." skipped "database" "DB_SERVICE=none" elif [ -z "$WANT_DB" ]; then say " nothing to do — no compose file here, and DB_SERVICE is unset." skipped "database" "no compose file and no DB_SERVICE" else # The status decides whether this knows anything; the output decides what it # knows. A successful listing with nothing in it means nothing is running, # which is knowledge — only a failed listing is ignorance. running="" running_read="" if running=$(compose_running); then running_read="yes"; fi if [ -n "$running_read" ] && has_line "$running" "$DB_SERVICE"; then say " skipped — '$DB_SERVICE' is already running." skipped "database" "'$DB_SERVICE' already running" else # `up -d` reconciles a service that is already up, so an unreadable list is # not a reason to stop — but it is a reason not to claim the service was down. [ -n "$running_read" ] || \ say " could not tell whether '$DB_SERVICE' is already running; 'up -d' is a no-op if it is." if ! run "${COMPOSE[@]}" -f "$COMPOSE_FILE" up -d "$DB_SERVICE"; then die "starting '$DB_SERVICE' failed. Nothing was migrated and no server was started." fi if [ -z "$DRY_RUN" ] && [ -n "$HAVE_DB_ENDPOINT" ]; then say " waiting for $DB_HOST:$DB_PORT (up to ${DB_WAIT}s)…" waited=0 state=2 # Probes before testing the clock, so DB_WAIT=0 still reports what it found # rather than the "never looked" answer the initial value carries. while :; do probe_tcp "$DB_HOST" "$DB_PORT" state=$? [ "$state" -ne 1 ] && break [ "$waited" -ge "$DB_WAIT" ] && break sleep 1 waited=$((waited + 1)) done case "$state" in 0) say " $DB_HOST:$DB_PORT answers after ${waited}s." ;; 2) say " WARNING: the port could not be probed, so this waited for nothing" say " definite. Continuing; the migrate step is the real test." ;; *) say " $DB_HOST:$DB_PORT did not answer within ${DB_WAIT}s." say " The container may still be starting — check" say " '${COMPOSE[*]} -f $COMPOSE_FILE logs $DB_SERVICE'." die "refusing to migrate against a database that has not answered." ;; esac elif [ -z "$DRY_RUN" ]; then say " started. Readiness was NOT verified: ${DB_ENDPOINT_WHY:-no endpoint to probe}." fi fi fi # --------------------------------------------------------------------------- # Step 3 — migrations # --------------------------------------------------------------------------- say "" say "step 3/4: migrations" # The stamp lives inside the git directory: a marker file in the working tree is # one `git add -A` from being committed, and a gitignore entry is a second thing # to keep correct in every project this template lands in. # # Only when that git directory belongs to THIS project. Run from inside another # checkout, `--git-dir` names that checkout, and writing there would record this # project's migration state in somebody else's repository — the same confusion # the project-root block at the top refuses to make. STATE_DIR="" if [ -n "$GIT_ROOT" ] && [ "$GIT_ROOT" = "$ROOT" ]; then GIT_DIR=$(git rev-parse --git-dir 2>/dev/null) [ -n "$GIT_DIR" ] && [ -d "$GIT_DIR" ] && STATE_DIR="$GIT_DIR/dev.sh" fi MIGRATE_STAMP="${STATE_DIR:+$STATE_DIR/migrated}" if [ -n "$SKIP_MIGRATE" ]; then say " skipped (--skip-migrate)." skipped "migrations" "--skip-migrate" elif [ -z "$HAVE_MIGRATE" ]; then say " nothing to do — no migrate step found (looked for ${MIGRATE_LOOKED:-nothing readable})." skipped "migrations" "no migrate step found" else fresh="" if [ -n "$MIGRATIONS_DIR" ] && [ -n "$MIGRATE_STAMP" ] && [ -f "$MIGRATE_STAMP" ]; then if [ ! -d "$MIGRATIONS_DIR" ]; then say " note: MIGRATIONS_DIR '$MIGRATIONS_DIR' does not exist, so nothing can be compared against the last run." # The status again, not the emptiness: a find that cannot read the directory # prints nothing, which is the same output as "nothing has changed" — and the # cost of confusing them is a schema change that silently never runs. Not # piped into `head` for the same reason: SIGPIPE would make success and # failure return the same thing under `pipefail`. elif newer=$(find "$MIGRATIONS_DIR" -type f -newer "$MIGRATE_STAMP" 2>/dev/null); then [ -z "$newer" ] && fresh="yes" else say " note: could not list $MIGRATIONS_DIR, so this migrates rather than assuming nothing changed." fi fi if [ -n "$fresh" ]; then say " skipped — nothing in $MIGRATIONS_DIR has changed since the last successful run." say " Delete $MIGRATE_STAMP to force one." skipped "migrations" "no new files in $MIGRATIONS_DIR" else if [ -z "$MIGRATIONS_DIR" ]; then # Honest about why it cannot skip: the migration tool's own ledger lives in # the database, and this script will not open a connection to read it. say " running every time — MIGRATIONS_DIR is unset, so there is nothing to compare." fi say " $MIGRATE_DESC ($MIGRATE_WHY)" if ! run "${MIGRATE_ARGV[@]}"; then die "migrations failed. No server was started." fi if [ -z "$DRY_RUN" ] && [ -n "$MIGRATE_STAMP" ]; then mkdir -p "$STATE_DIR" 2>/dev/null && : > "$MIGRATE_STAMP" 2>/dev/null \ || say " note: could not write $MIGRATE_STAMP, so the next run will migrate again." fi fi fi # --------------------------------------------------------------------------- # Step 4 — the dev server # --------------------------------------------------------------------------- # Recapped before the server takes the terminal, because that is the last moment # anybody reads this output: a step that skipped itself for a good reason and a # step that skipped itself because nothing was configured produce the same # silence otherwise. if [ ${#SKIPPED[@]} -gt 0 ]; then say "" say "steps skipped this run:" for entry in ${SKIPPED[@]+"${SKIPPED[@]}"}; do IFS=$'\t' read -r what why <<<"$entry" say " $what — $why" done fi say "" say "step 4/4: dev server" if [ -n "$SKIP_SERVER" ]; then say " not started (--skip-server). Everything else is in place." exit 0 fi if [ -n "$DRY_RUN" ]; then say " would exec: $SERVER_DESC ($SERVER_WHY)" say "" say "--dry-run: nothing above was executed." exit 0 fi say " $SERVER_DESC ($SERVER_WHY)" say "" # `exec` with no arguments is not an error: it applies the (absent) redirections # to this shell and carries on to the end of the file, which exits 0. A dev # server that was never started would be reported as one that was. [ ${#SERVER_ARGV[@]} -gt 0 ] || die "internal error: reached step 4 with no server command. Nothing was started; set DEV_SERVER_CMD." # exec, so Ctrl-C reaches the server rather than this script, and so nothing here # outlives it holding a terminal the server thinks it owns. exec "${SERVER_ARGV[@]}"