#!/usr/bin/env bash # appctl — convenience wrapper for the irdb docker compose stack and CI checks. # # The canonical stack is api + ui + migrate (docker-compose.yml). The # scheduler sidecar (compose.scheduler.yml) is opt-in via --scheduler. # # Per-subproject scope is accepted as an optional positional arg on the # check commands (lint/stan/test/audit/check). With no arg, the command # runs against both api and ui — the same coverage as scripts/ci.sh. # # Lint, stan, test, audit and check all run in ephemeral composer/node # containers (mirroring scripts/ci.sh) so the host needs no PHP or Node # toolchain installed. set -euo pipefail # Resolve repo root regardless of where the user invokes us from. Works # whether called as bin/appctl or via the top-level ./appctl symlink. SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")" SCRIPT_DIR="$(dirname "$SCRIPT_PATH")" if [[ "$(basename "$SCRIPT_DIR")" == "bin" ]]; then REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" else REPO_ROOT="$SCRIPT_DIR" fi cd "$REPO_ROOT" PHP_IMAGE="composer:2" NODE_IMAGE="node:20-alpine" UID_GID="$(id -u):$(id -g)" usage() { cat <<'EOF' appctl — irdb docker compose / CI wrapper USAGE appctl [args] STACK (docker compose; canonical stack is api + ui + migrate) appctl start [--scheduler] [-d] bring the stack up (foreground; -d to detach) appctl stop [--scheduler] stop and remove containers appctl build [api|ui|scheduler] build images (no arg = api + ui) appctl shell [api|ui] bash into a running container (default: api) appctl logs [service] follow logs (no arg = all services) appctl ps list stack status (incl. scheduler if running) appctl migrate run the one-shot migrate container CHECKS (ephemeral containers, no running stack required) appctl lint [api|ui] composer cs (php-cs-fixer --dry-run) appctl stan [api|ui] composer stan (phpstan) appctl test [api|ui] composer test (phpunit) appctl audit [api|ui] composer audit --no-dev appctl check [api|ui] lint + stan + audit + test for the target(s) appctl ci full CI pipeline (delegates to scripts/ci.sh) OTHER appctl help show this message EOF } die() { printf 'appctl: %s\n' "$*" >&2 exit 2 } # --- compose helpers -------------------------------------------------- # Canonical stack only (api + ui + migrate). compose_main() { docker compose -f docker-compose.yml "$@" } # Canonical stack plus the scheduler sidecar overlay. Used for start # --scheduler, build scheduler, and read-only commands (logs / ps) that # should still see the sidecar when it's running. compose_full() { docker compose -f docker-compose.yml -f compose.scheduler.yml "$@" } # Parse `--scheduler` and `-d|--detach` flags out of the args. # Sets globals: SCHED, DETACH, REST (remaining positional args). parse_stack_flags() { SCHED=0 DETACH=0 REST=() while (($#)); do case "$1" in --scheduler) SCHED=1 ;; -d|--detach) DETACH=1 ;; --) shift; REST+=("$@"); break ;; *) REST+=("$1") ;; esac shift done } # --- subproject helpers (echo space-separated for `for` loops) -------- resolve_targets() { local arg="${1:-}" case "$arg" in "") echo "api ui" ;; api|ui) echo "$arg" ;; *) die "unknown subproject: $arg (try: api|ui)" ;; esac } # Run a command inside a one-off composer container, mounted at /app. # Mirrors scripts/ci.sh::run_php so behavior stays consistent. run_php() { local dir="$1"; shift mkdir -p "$HOME/.composer-cache" docker run --rm \ -u "$UID_GID" \ -v "$REPO_ROOT/$dir":/app \ -v "$HOME/.composer-cache":/tmp \ -w /app \ -e COMPOSER_HOME=/tmp/composer \ -e COMPOSER_CACHE_DIR=/tmp/composer-cache \ -e XDG_CONFIG_HOME=/tmp \ "$PHP_IMAGE" "$@" } # Bootstrap vendor/ on demand so tests/lint don't fail with a confusing # "vendor/bin/phpunit not found" the first time. ensure_vendor() { local dir="$1" [[ -d "$REPO_ROOT/$dir/vendor" ]] && return 0 printf '[appctl] %s/vendor missing — running composer install\n' "$dir" run_php "$dir" composer install --no-interaction --prefer-dist } # --- subcommands ------------------------------------------------------ cmd_start() { parse_stack_flags "$@" local up_args=(up) [[ "$DETACH" == "1" ]] && up_args+=(-d) if [[ "$SCHED" == "1" ]]; then compose_full "${up_args[@]}" "${REST[@]}" else compose_main "${up_args[@]}" "${REST[@]}" fi } cmd_stop() { parse_stack_flags "$@" # `down` operates on the project name and tears down everything # currently running, regardless of which overlays were used to # start it — but layering the scheduler overlay keeps things tidy # when --scheduler was passed. if [[ "$SCHED" == "1" ]]; then compose_full down "${REST[@]}" else compose_main down "${REST[@]}" fi } cmd_build() { local target="${1:-}" case "$target" in "") compose_main build ;; api|ui|migrate) compose_main build "$target" ;; scheduler) compose_full build scheduler ;; *) die "unknown build target: $target (try: api|ui|scheduler|migrate)" ;; esac } cmd_shell() { local service="${1:-api}" case "$service" in api|ui) ;; *) die "unknown shell target: $service (try: api|ui)" ;; esac # Both images are alpine-based today; fall back to sh if bash isn't # present so this keeps working if the base image changes. compose_main exec "$service" \ sh -c 'command -v bash >/dev/null && exec bash || exec sh' } cmd_logs() { local service="${1:-}" if [[ -z "$service" ]]; then compose_full logs -f else compose_full logs -f "$service" fi } cmd_ps() { compose_full ps } cmd_migrate() { compose_main run --rm migrate } cmd_lint() { local target for target in $(resolve_targets "${1:-}"); do ensure_vendor "$target" printf '\n[appctl] %s: composer cs\n' "$target" run_php "$target" composer cs done } cmd_stan() { local target for target in $(resolve_targets "${1:-}"); do ensure_vendor "$target" printf '\n[appctl] %s: composer stan\n' "$target" run_php "$target" composer stan done } cmd_test() { local target for target in $(resolve_targets "${1:-}"); do ensure_vendor "$target" printf '\n[appctl] %s: composer test\n' "$target" run_php "$target" composer test done } cmd_audit() { local target for target in $(resolve_targets "${1:-}"); do ensure_vendor "$target" printf '\n[appctl] %s: composer audit\n' "$target" run_php "$target" composer audit --no-dev done } cmd_check() { local target="${1:-}" cmd_lint "$target" cmd_stan "$target" cmd_audit "$target" cmd_test "$target" } cmd_ci() { [[ -x scripts/ci.sh ]] || die "scripts/ci.sh missing or not executable" exec scripts/ci.sh } # --- dispatch --------------------------------------------------------- cmd="${1:-help}" shift || true case "$cmd" in start) cmd_start "$@" ;; stop) cmd_stop "$@" ;; build) cmd_build "$@" ;; shell) cmd_shell "$@" ;; logs) cmd_logs "$@" ;; ps) cmd_ps ;; migrate) cmd_migrate ;; lint) cmd_lint "$@" ;; stan) cmd_stan "$@" ;; test) cmd_test "$@" ;; audit) cmd_audit "$@" ;; check) cmd_check "$@" ;; ci) cmd_ci ;; help|-h|--help|"") usage ;; *) die "unknown command: $cmd (try: appctl help)" ;; esac