Jelajahi Sumber

feat: add appctl wrapper for compose stack and CI checks

Single entry point for managing the docker compose stack (start/stop/
build/shell/logs/ps/migrate) with --scheduler to layer the sidecar
overlay, plus per-subproject lint/stan/test/audit/check that run in
ephemeral composer containers (mirroring scripts/ci.sh) so the host
needs no PHP toolchain. Optional [api|ui] arg scopes checks to one
subproject; omitted runs both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ClaudePriv@chiappa.zhdk.ch 20 jam lalu
induk
melakukan
db048ca009
2 mengubah file dengan 269 tambahan dan 0 penghapusan
  1. 1 0
      appctl
  2. 268 0
      bin/appctl

+ 1 - 0
appctl

@@ -0,0 +1 @@
+bin/appctl

+ 268 - 0
bin/appctl

@@ -0,0 +1,268 @@
+#!/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 <command> [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