#!/usr/bin/env bash # # Task runner for a Vaadin and Spring Boot project, built with Maven. # # Every task pins JAVA_HOME to the JDK the project is built for, because a bare # `mvn` picks whatever the machine defaults to — and under a newer JDK than Lombok # supports it silently generates no getters, so the build dies with bogus # "cannot find symbol" errors. # # Everything specific to one project is in run.conf beside it; nothing in this file # names a project, so projects share it as it stands. A task this runner does not # have goes in run.tasks.sh, for the same reason. # # Usage: ./run.sh (or `help` to list tasks). set -euo pipefail cd "$(dirname "$0")" readonly PROJECT_CONFIG_FILE="run.conf" # Tasks a project has that this runner does not: a file of task_ functions, # sourced below the helpers so they can call setup and run_mvn like the tasks here # do. Keeping them out of this file is what lets a project take a newer runner # without a merge. It may also define project_usage, printed under the task list. readonly PROJECT_TASKS_FILE="run.tasks.sh" if [[ ! -f "${PROJECT_CONFIG_FILE}" ]]; then echo "No ${PROJECT_CONFIG_FILE} found" >&2 exit 1 fi source "./${PROJECT_CONFIG_FILE}" # A project has to answer these two; nothing sensible can be assumed for either. for setting in PROJECT_NAME JAVA_VERSION; do if [[ -z "${!setting:-}" ]]; then echo "${PROJECT_CONFIG_FILE} sets no ${setting}" >&2 exit 1 fi done unset setting # Everything else falls back to the layout a Vaadin project has by default, so a # configuration only names what it decides. See run.conf for what each one is. : "${ENV_DIR:=environment/dev}" : "${CONTAINER_REQUIRED:=true}" : "${CONTAINER_PREFIX:=${PROJECT_NAME,,}-dev}" : "${BUNDLE_DIR:=src/main/bundles}" : "${STYLES_CSS:=src/main/resources/META-INF/resources/styles.css}" : "${IT_PROFILE:=}" : "${COMMIT_PROPERTY:=}" readonly PROJECT_NAME JAVA_VERSION ENV_DIR CONTAINER_REQUIRED CONTAINER_PREFIX readonly BUNDLE_DIR STYLES_CSS IT_PROFILE COMMIT_PROPERTY readonly DEV_COMPOSE_FILE="${ENV_DIR}/compose.yaml" readonly QUADLET_DIR="${ENV_DIR}/quadlet" readonly QUADLET_INSTALL_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/containers/systemd" # Filled in by setup, and the only mvn any task runs. MAVEN="" # Resolve the configured JDK from SDKMAN; fall back to whatever JAVA_HOME is set. resolve_java_home() { local jdk jdk=$(ls -d "${HOME}"/.sdkman/candidates/java/"${JAVA_VERSION}"* 2>/dev/null | head -1 || true) if [[ -n "${jdk}" ]]; then export JAVA_HOME="${jdk}" elif [[ -z "${JAVA_HOME:-}" ]]; then echo "No JDK ${JAVA_VERSION} found under ~/.sdkman and JAVA_HOME is unset." >&2 exit 1 fi echo "Using JAVA_HOME=${JAVA_HOME}" } # Which runtime this machine has, or nothing at all. docker first: where both are # installed, docker is the one whose context and compose plugin this script drives, # and podman is usually there as its backend. container_runtime() { if command -v docker >/dev/null 2>&1; then echo "docker" elif command -v podman >/dev/null 2>&1; then echo "podman" fi } require_container_runtime() { local runtime runtime=$(container_runtime) if [[ -z "${runtime}" ]]; then echo "No container runtime found. Either docker or podman is required." >&2 exit 1 fi echo "${runtime}" } # Testcontainers finds the daemon through DOCKER_HOST or /var/run/docker.sock and # looks nowhere else. Neither CLI advertises itself there: docker keeps its endpoint # in a "context", so on Colima and Rancher Desktop the CLI works while Testcontainers # reports "Could not find a valid Docker environment", and rootless podman puts its # socket under /run/user/. So ask whichever CLI this machine has where its # socket is, and hand that to Testcontainers. # # The override is for Ryuk, the cleanup sidecar, which mounts the socket from inside # its own container and so needs a path rather than an endpoint. The two runtimes # disagree on which path: a VM-backed docker sees /var/run/docker.sock however the # host spells it, while rootless podman runs no VM and needs the real path. # # Starting the socket is deliberately left to whoever runs this — the task says what # to run and stops rather than enabling a system service behind their back. resolve_container_runtime() { if [[ -n "${DOCKER_HOST:-}" ]]; then return fi if [[ -S /var/run/docker.sock ]]; then return fi local runtime endpoint="" override="" runtime=$(require_container_runtime) case "${runtime}" in docker) endpoint=$(docker context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null || true) override="/var/run/docker.sock" ;; podman) local socket socket=$(podman info --format '{{.Host.RemoteSocket.Path}}' 2>/dev/null || true) if [[ -n "${socket}" ]]; then endpoint="unix://${socket}" override="${socket}" fi ;; esac if [[ -z "${endpoint}" ]]; then echo "${runtime} is installed but would not say where its socket is." >&2 echo "Set DOCKER_HOST by hand, or check that ${runtime} itself works." >&2 exit 1 fi # podman reports its socket path whether or not anything is listening on it, and # a docker context outlives the daemon it points at, so neither answer proves the # runtime is up. Say which command starts it instead of failing deep inside a test. if [[ "${endpoint}" == unix://* && ! -S "${endpoint#unix://}" ]]; then echo "${runtime} reports its socket at ${endpoint#unix://}, but nothing is listening." >&2 if [[ "${runtime}" == "podman" ]]; then echo "Start it with: systemctl --user start podman.socket" >&2 else echo "Start the docker daemon, then retry." >&2 fi exit 1 fi export DOCKER_HOST="${endpoint}" export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE="${TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE:-${override}}" echo "Using ${runtime} at DOCKER_HOST=${DOCKER_HOST}" } # The mvn to run. A bare `mvn` is on the PATH of a login shell only, because SDKMAN # installs it under ~/.sdkman and the profile is what puts it there — so anything # starting this script without one, the Claude Code preview pane included, dies with # "mvn: command not found". Resolve it the way JAVA_HOME is resolved, rather than # depending on how this script happened to be started. resolve_maven() { if command -v mvn >/dev/null 2>&1; then MAVEN="mvn" echo "Using MAVEN=${MAVEN} (from the PATH)" return fi local candidate candidate=$(ls -d "${HOME}"/.sdkman/candidates/maven/current/bin/mvn \ "${HOME}"/.sdkman/candidates/maven/*/bin/mvn 2>/dev/null | head -1 || true) if [[ -z "${candidate}" ]]; then echo "No mvn on the PATH and none under ~/.sdkman." >&2 echo "Install Maven, or start this from a shell where SDKMAN is initialised." >&2 exit 1 fi MAVEN="${candidate}" echo "Using MAVEN=${MAVEN}" } # What every task needs before it can do anything: the pinned JDK, the mvn that goes # with it, and the container runtime. Resolved together, in one place, so that a task # body is the work it is named after and nothing else. # # The runtime is not only the test suite's concern, which is why a project that needs # one resolves it here rather than in the tasks that obviously want it: the application # itself will not start without the services it talks to, and `package` runs the tests # on the way through. A machine missing any of it is told so before the work starts # rather than partway into it. # # No exceptions among the tasks either, not even the ones that never reach for a # container: touching a stylesheet on a machine that cannot run the application # accomplishes nothing, and failing at the first task is what says so. CONTAINER_REQUIRED # is the one switch, and it is the project's answer rather than the task's. setup() { resolve_java_home resolve_maven if [[ "${CONTAINER_REQUIRED}" == "true" ]]; then resolve_container_runtime fi } # Every build carries the deployed commit SHA when the project asks for one, so an # artefact can be traced back to what produced it. Resolved from the working tree and # passed to every mvn invocation. A non-repo checkout is then a hard error by design: # an unidentifiable build should never be produced. run_mvn() { local -a properties=() if [[ -n "${COMMIT_PROPERTY}" ]]; then local commit if ! commit=$(git rev-parse HEAD 2>/dev/null); then echo "Cannot resolve the git commit (not a git repository?)." >&2 echo "${COMMIT_PROPERTY} is mandatory; refusing to build." >&2 exit 1 fi properties+=("-D${COMMIT_PROPERTY}=${commit}") fi "${MAVEN}" "$@" "${properties[@]}" } # Bump styles.css mtime. styles.css is only @import statements; editing an # imported partial (colors.css, grid.css, ...) leaves styles.css unchanged, so # the browser serves a 304 and keeps the stale partial. Touching the entry file # busts that cache. touch_styles() { touch "${STYLES_CSS}" echo "Touched ${STYLES_CSS}" } # Remove the cached frontend bundles. Vaadin's dev server reuses an existing # bundle and logs "a development mode bundle build is not needed", so a changed # @CssImport(themeFor=...) is ignored until both bundles are gone. Deleting only # dev.bundle is not enough — a leftover prod.bundle is still treated as valid. clear_bundles() { rm -rf "${BUNDLE_DIR}/dev.bundle" "${BUNDLE_DIR}/prod.bundle" target/dev-bundle echo "Cleared cached frontend bundles" } task_compile() { setup run_mvn -q compile echo "Compiled. spring-boot-devtools will hot-restart a running app." } # Force a clean frontend rebuild after a @CssImport / @JsModule change: drop the # cached bundles, touch styles.css, then compile so devtools rebuilds the bundle. task_bundle() { setup clear_bundles touch_styles run_mvn -q compile echo "Bundle cleared and recompiled. Reload the browser to pick up the new bundle." } task_styles() { setup touch_styles run_mvn -q process-resources echo "Stylesheets processed. Reload the browser." } # Docker renamed compose from a script to a subcommand and both are still in the # wild, so find whichever this machine has rather than guessing. Only the docker # shapes: podman drives the stack through quadlets instead. compose() { if docker compose version >/dev/null 2>&1; then docker compose -f "${DEV_COMPOSE_FILE}" "$@" elif command -v docker-compose >/dev/null 2>&1; then docker-compose -f "${DEV_COMPOSE_FILE}" "$@" else echo "No Compose found. Install the Docker Compose plugin (docker compose)" >&2 echo "or the standalone docker-compose, then retry." >&2 exit 1 fi } # The units of the development stack, derived from what is in the quadlet directory # so that adding a service there needs no change here — the same property the compose # file has. quadlet_services() { local unit for unit in "${QUADLET_DIR}"/*.container.in; do echo "$(basename "${unit}" .container.in).service" done } # Copy the units into the directory systemd's generator reads, substituting the one # thing a quadlet cannot state for itself: where this checkout lives, which the two # files mounted into Keycloak need as an absolute path. install_quadlets() { mkdir -p "${QUADLET_INSTALL_DIR}" local unit for unit in "${QUADLET_DIR}"/*.in; do sed "s|@ENV_DIR@|${PWD}/${ENV_DIR}|g" \ "${unit}" > "${QUADLET_INSTALL_DIR}/$(basename "${unit}" .in)" done systemctl --user daemon-reload echo "Installed quadlet units into ${QUADLET_INSTALL_DIR}" } # podman drives the stack through quadlets rather than compose: systemd starts the # units, orders them, and — because every container declares Notify=healthy — reports # one as started only once its healthcheck passes. That is what `up --wait` and # `depends_on: service_healthy` bought under compose, so nothing is lost by not # having a compose file in the loop. quadlet_env() { local action="$1" local -a services mapfile -t services < <(quadlet_services) case "${action}" in up) install_quadlets systemctl --user start "${services[@]}" echo "Development stack is up. ${PROJECT_NAME} needs no configuration to reach it." echo "It is started, not enabled: add 'systemctl --user enable' yourself to" echo "get it back after a reboot." ;; down) systemctl --user stop "${services[@]}" echo "Development stack stopped; its data is kept." ;; logs) local -a follow=() local service for service in "${services[@]}"; do follow+=(--unit "${service}") done journalctl --user --follow "${follow[@]}" ;; reset) systemctl --user stop "${services[@]}" # Every volume of the stack rather than a named one, for the same reason # the services are read from a directory: this should not need editing # when the stack gains storage. podman volume ls --filter name="${CONTAINER_PREFIX}" --quiet \ | xargs --no-run-if-empty podman volume rm --force >/dev/null echo "Development stack stopped and its data thrown away." ;; esac } compose_env() { local action="$1" case "${action}" in # --wait rather than plain -d: every service declares a healthcheck, and the # app starting before them is what turns a slow container into a confusing # connection or discovery error at startup. up) compose up -d --wait && echo "Development stack is up. ${PROJECT_NAME} needs no configuration to reach it." ;; down) compose stop && echo "Development stack stopped; its data is kept." ;; logs) compose logs -f ;; reset) compose down -v && echo "Development stack stopped and its data thrown away." ;; esac } # The whole development stack in one task, because an application that needs a # database, a browser and an identity provider needs all of them. # # Which containers that means is never this function's decision — compose reads the # stack's compose.yaml, quadlets come from its quadlet directory — so adding a # service needs no change here. task_env() { setup if [[ "${CONTAINER_REQUIRED}" != "true" ]]; then echo "CONTAINER_REQUIRED is ${CONTAINER_REQUIRED}: this project is configured to need" >&2 echo "no containers, so there is no development stack for this task to bring up." >&2 exit 1 fi local action="${1:-up}" case "${action}" in up|down|logs|reset) ;; *) echo "Unknown env action: ${action} (expected up, down, logs or reset)" >&2 exit 1 ;; esac local runtime runtime=$(require_container_runtime) if [[ "${runtime}" == "podman" ]]; then quadlet_env "${action}" else compose_env "${action}" fi } # Warm the local Maven repository ahead of a build, so a later task needs no # network. dependency:resolve would walk the dependency tree alone and leave the # build plugins out; go-offline takes both. # # It cannot be exhaustive: surefire picks its provider (surefire-junit-platform) # off the test classpath as it runs, and no resolution goal sees that in advance. # The first `test` downloads it. That is a slow first run, not a broken one. task_deps() { setup run_mvn --batch-mode --no-transfer-progress dependency:go-offline } task_test() { setup # The pom's JaCoCo gate runs with these, and fails the task under its threshold. run_mvn test "$@" } task_run() { setup run_mvn spring-boot:run } # Same as `run`, but named for the Claude Code preview pane, which launches the # app through this task (see .claude/launch.json) so the preview goes through # run.sh — the pinned JDK and the bundle gotchas — like every other task, instead # of invoking mvn directly. task_preview() { task_run } # Unit tests plus the Playwright integration tests, against a production build. # # The IT profile is what puts the app in production mode; without it the ITs open a # dev-mode page and find an empty screen. The bundles are cleared first because a # dev.bundle left behind by `./run.sh run` makes the frontend build decide "a # production mode bundle build is not needed" and the app then serves a bundle # that fails to boot — the same trap `clean` exists for, and `mvn clean` alone # does not clear it since the bundles live under src/. task_verify() { setup clear_bundles local -a profile=() if [[ -n "${IT_PROFILE}" ]]; then profile+=("-P${IT_PROFILE}") fi run_mvn clean verify "${profile[@]}" "$@" } # Full production build. task_package() { setup run_mvn clean package "$@" } task_clean() { setup clear_bundles "${MAVEN}" clean } # Describes only what this runner does, never what a project's build is configured to # do with it: shared text that claims a coverage gate or a browser test is wrong for # the first project that has neither, and a listed task that then refuses is worse # than one that was never offered. usage() { echo "Usage: ./run.sh " echo echo "Tasks:" if [[ "${CONTAINER_REQUIRED}" == "true" ]]; then cat <<'EOF' env [act] The whole development stack — quadlets under podman, compose under docker: up (default), down, logs, reset (throws the data away) EOF fi cat <<'EOF' deps Pre-download dependencies and build plugins, so a later task needs no network compile Compile sources (triggers a devtools hot-restart of a running app) bundle Clear cached frontend bundles + touch styles.css + recompile (use after changing a @CssImport themeFor / @JsModule) styles Touch styles.css so the browser reloads @imported CSS partials test Run the unit tests (mvn test) verify Unit tests + integration tests (mvn clean verify) run Start the app with spring-boot:run (dev mode) preview Alias of run, launched by the Claude Code preview pane package Full production build (mvn clean package) clean mvn clean + remove cached bundles help Show this message EOF if declare -F project_usage >/dev/null; then project_usage fi } main() { local task="${1:-help}" case "${task}" in compile) task_compile ;; bundle) task_bundle ;; styles) task_styles ;; env) task_env "${2:-up}" ;; deps) task_deps ;; test) task_test "${@:2}" ;; verify) task_verify "${@:2}" ;; run) task_run ;; preview) task_preview ;; package) task_package "${@:2}" ;; clean) task_clean ;; help|-h|--help) usage ;; *) # Anything the project's own task file defines, dispatched by name. Only # a declared function matches, so a mistyped task is still a mistyped # task rather than a silent no-op. if declare -F "task_${task}" >/dev/null; then "task_${task}" "${@:2}" else echo "Unknown task: ${task}" >&2 echo >&2 usage >&2 exit 1 fi ;; esac } if [[ -n "${PROJECT_TASKS_FILE}" && -f "${PROJECT_TASKS_FILE}" ]]; then source "./${PROJECT_TASKS_FILE}" fi main "$@"