1283 lines
55 KiB
Bash
Executable File
1283 lines
55 KiB
Bash
Executable File
#!/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
|
|
# </dev/null because an nc that does not understand -z connects instead, and
|
|
# a connecting nc reads its stdin — this script's stdin, which belongs to
|
|
# whoever is about to answer a prompt from the dev server.
|
|
out=$(nc -z -w 3 "$host" "$port" 2>&1 </dev/null); rc=$?
|
|
[ "$rc" -eq 0 ] && return 0
|
|
|
|
# An nc that failed is not the same fact as a port that refused, and only one
|
|
# of them is a measurement. busybox's nc has no -z at all: it prints its
|
|
# usage and exits 1, which the obvious version of this reported as "nothing
|
|
# is listening" — the same mistake the /dev/tcp branch below was written to
|
|
# avoid, made one branch earlier. So only the messages a real connection
|
|
# attempt produces are allowed to mean "closed", and anything else falls
|
|
# through to the second method rather than being answered.
|
|
case "$out" in
|
|
''|*"efused"*|*"imed out"*|*"o route to host"*|*"ame or service not known"*|\
|
|
*"odename nor servname"*|*"nknown host"*|*"ame does not resolve"*) return 1 ;;
|
|
esac
|
|
fi
|
|
|
|
if have timeout; then
|
|
# host and port are ARGUMENTS to the probing shell, not text spliced into its
|
|
# command line. They come from a connection URL, which is data out of the
|
|
# environment: spliced in, a `$(...)` in the host field of that URL is a
|
|
# command this script runs on behalf of whoever wrote the URL — and it runs
|
|
# during --check, which promises to change nothing. A host with a space in it
|
|
# broke the redirect the same way, for a less interesting reason.
|
|
out=$(timeout 5 bash -c 'exec 3<>/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 `<the whole value>:
|
|
# 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[@]}"
|