appctl 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. #!/usr/bin/env bash
  2. # appctl — convenience wrapper for the dev/prod compose split.
  3. #
  4. # Replaces the old Makefile. The dev compose chain layers
  5. # docker-compose.dev.yml on top of docker-compose.yml so source bind
  6. # mounts and the css-watcher sidecar only show up for `appctl dev …`.
  7. # Prod uses docker-compose.yml alone.
  8. set -euo pipefail
  9. # Resolve the repo root (parent of bin/) regardless of where the user
  10. # invokes us from, so cd into the right place even when called via the
  11. # top-level ./appctl symlink.
  12. SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
  13. REPO_ROOT="$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)"
  14. cd "$REPO_ROOT"
  15. # Exported so the css-watcher service can run as the host user via
  16. # `user: "${HOST_UID}:${HOST_GID}"` in docker-compose.dev.yml. Files
  17. # the watcher writes into bind-mounted host paths
  18. # (public/assets/css/app.css, public/assets/js/vendor/*) then land
  19. # with normal ownership.
  20. export HOST_UID="${HOST_UID:-$(id -u)}"
  21. export HOST_GID="${HOST_GID:-$(id -g)}"
  22. COMPOSE_PROD=(docker compose)
  23. COMPOSE_DEV=(docker compose -f docker-compose.yml -f docker-compose.dev.yml)
  24. usage() {
  25. cat <<'EOF'
  26. appctl — Sprint Planner dev/prod/test wrapper
  27. USAGE
  28. appctl <command> [subcommand]
  29. DEV
  30. appctl dev start start dev stack (app + css-watcher) in foreground
  31. appctl dev stop stop and remove dev containers
  32. appctl dev build rebuild dev images
  33. appctl dev shell bash into the running app container
  34. appctl dev logs tail logs from the dev stack
  35. PROD
  36. appctl prod start start prod stack detached
  37. appctl prod stop stop and remove prod containers
  38. appctl prod build rebuild prod images
  39. appctl prod upgrade [VER] stop, fetch, checkout VER, rebuild, start
  40. VER defaults to the `latest` git tag;
  41. `test` is a virtual keyword that resolves
  42. to origin/main HEAD after fetch.
  43. CHECKS (one-shot containers, no running stack required)
  44. appctl lint php -l on src/ + tests/
  45. appctl test phpunit
  46. appctl check lint + test (used by the /check Claude Code skill)
  47. OTHER
  48. appctl completion print the bash completion script to stdout
  49. appctl help show this message
  50. EOF
  51. }
  52. die() {
  53. printf 'appctl: %s\n' "$*" >&2
  54. exit 2
  55. }
  56. # --- bash completion auto-source on first run --------------------------
  57. #
  58. # We ship bin/appctl-completion.bash next to this script. On the very
  59. # first interactive invocation we offer to add a `source` line to the
  60. # user's ~/.bashrc — only once, gated by a marker file in $HOME so the
  61. # prompt never appears again, and only when stdin is a TTY (CI / piped
  62. # invocations are silent).
  63. maybe_offer_completion() {
  64. [[ -t 0 && -t 1 ]] || return 0
  65. [[ "${APPCTL_NO_COMPLETION_PROMPT:-}" != "1" ]] || return 0
  66. local marker="${HOME}/.config/appctl/completion-installed"
  67. [[ ! -e "$marker" ]] || return 0
  68. local completion_script="${REPO_ROOT}/bin/appctl-completion.bash"
  69. [[ -f "$completion_script" ]] || return 0
  70. local bashrc="${HOME}/.bashrc"
  71. if [[ -f "$bashrc" ]] && grep -Fq "appctl-completion.bash" "$bashrc"; then
  72. # Already wired up by a previous repo checkout; just write the
  73. # marker so we stop asking.
  74. mkdir -p "$(dirname "$marker")"
  75. : > "$marker"
  76. return 0
  77. fi
  78. printf '\n[appctl] bash completion is not yet installed.\n'
  79. printf ' Add `source %s` to %s? [y/N] ' \
  80. "$completion_script" "$bashrc"
  81. local reply
  82. read -r reply || reply=""
  83. case "$reply" in
  84. y|Y|yes|YES)
  85. mkdir -p "$(dirname "$marker")"
  86. {
  87. printf '\n# appctl bash completion (added by appctl on first run)\n'
  88. printf 'source %q\n' "$completion_script"
  89. } >> "$bashrc"
  90. : > "$marker"
  91. printf '[appctl] added. Open a new shell or `source %s` to activate.\n\n' "$bashrc"
  92. ;;
  93. *)
  94. mkdir -p "$(dirname "$marker")"
  95. : > "$marker"
  96. printf '[appctl] skipped. Re-run with APPCTL_NO_COMPLETION_PROMPT=1 to silence; delete %s to ask again.\n\n' "$marker"
  97. ;;
  98. esac
  99. }
  100. # --- subcommands -------------------------------------------------------
  101. cmd_dev() {
  102. local sub="${1:-}"
  103. case "$sub" in
  104. start) "${COMPOSE_DEV[@]}" up ;;
  105. stop) "${COMPOSE_DEV[@]}" down ;;
  106. build) "${COMPOSE_DEV[@]}" build ;;
  107. shell) "${COMPOSE_DEV[@]}" exec app bash ;;
  108. logs) "${COMPOSE_DEV[@]}" logs -f ;;
  109. ""|help|-h|--help) usage ;;
  110. *) die "unknown dev subcommand: $sub (try: start|stop|build|shell|logs)" ;;
  111. esac
  112. }
  113. cmd_prod() {
  114. local sub="${1:-}"
  115. shift || true
  116. case "$sub" in
  117. start) "${COMPOSE_PROD[@]}" up -d ;;
  118. stop) "${COMPOSE_PROD[@]}" down ;;
  119. build) "${COMPOSE_PROD[@]}" build ;;
  120. upgrade) cmd_prod_upgrade "$@" ;;
  121. ""|help|-h|--help) usage ;;
  122. *) die "unknown prod subcommand: $sub (try: start|stop|build|upgrade)" ;;
  123. esac
  124. }
  125. # Pinned redeploy. Resolves VER (default `latest` tag; `test` ⇒ origin/main
  126. # HEAD after fetch) to a commit, then runs: stop → fetch → checkout →
  127. # build → start. Dirty trees prompt before any destructive step.
  128. cmd_prod_upgrade() {
  129. local version="${1:-latest}"
  130. if [[ -n "$(git status --porcelain)" ]]; then
  131. printf 'appctl: working tree has uncommitted changes:\n' >&2
  132. git status --short >&2
  133. printf '\nThe checkout step will fail or clobber these. Continue anyway? [y/N] ' >&2
  134. local reply
  135. read -r reply || reply=""
  136. case "$reply" in
  137. y|Y|yes|YES) ;;
  138. *) die "aborted by user (working tree dirty)" ;;
  139. esac
  140. fi
  141. printf 'appctl: git fetch --tags origin...\n'
  142. git fetch --tags origin
  143. local target_ref
  144. if [[ "$version" == "test" ]]; then
  145. target_ref="origin/main"
  146. else
  147. target_ref="$version"
  148. fi
  149. if ! git rev-parse --verify --quiet "${target_ref}^{commit}" >/dev/null; then
  150. die "unknown version: '$version' (expected a release tag, 'latest', or 'test')"
  151. fi
  152. local target_sha
  153. target_sha="$(git rev-parse --short "${target_ref}^{commit}")"
  154. printf 'appctl: upgrading to %s (%s)\n' "$version" "$target_sha"
  155. printf 'appctl: stopping prod stack...\n'
  156. "${COMPOSE_PROD[@]}" down
  157. printf 'appctl: git checkout --detach %s...\n' "$target_ref"
  158. git checkout --detach "$target_ref"
  159. printf 'appctl: building prod images...\n'
  160. "${COMPOSE_PROD[@]}" build
  161. printf 'appctl: starting prod stack...\n'
  162. "${COMPOSE_PROD[@]}" up -d
  163. printf 'appctl: upgrade to %s (%s) complete.\n' "$version" "$target_sha"
  164. }
  165. cmd_lint() {
  166. "${COMPOSE_DEV[@]}" --profile test run --rm tests \
  167. sh -c 'find src tests -name "*.php" -print0 | xargs -0 -n1 -P 4 php -l > /dev/null && echo "lint: OK"'
  168. }
  169. cmd_test() {
  170. "${COMPOSE_DEV[@]}" --profile test run --rm tests
  171. }
  172. cmd_check() {
  173. cmd_lint
  174. cmd_test
  175. }
  176. cmd_completion() {
  177. local completion_script="${REPO_ROOT}/bin/appctl-completion.bash"
  178. [[ -f "$completion_script" ]] || die "completion script missing: $completion_script"
  179. cat "$completion_script"
  180. }
  181. # --- dispatch ----------------------------------------------------------
  182. maybe_offer_completion
  183. cmd="${1:-help}"
  184. shift || true
  185. case "$cmd" in
  186. dev) cmd_dev "$@" ;;
  187. prod) cmd_prod "$@" ;;
  188. lint) cmd_lint ;;
  189. test) cmd_test ;;
  190. check) cmd_check ;;
  191. completion) cmd_completion ;;
  192. help|-h|--help|"") usage ;;
  193. *) die "unknown command: $cmd (try: appctl help)" ;;
  194. esac