#!/usr/bin/env bash set -euo pipefail source './parse_image_ref.sh' REGISTRY="registry.tomastm.com" REGISTRY_ABBR="tm0" PROJECT_DIR="$HOME/projects" PROJECT_ABBR="p" usage() { cat <<'EOF' Usage: dev [options] Commands: create -i, --image -p, --project exec [-- ...] connect list info stop [--kill] rm [--force|-f] Notes: - 'exec' treats the LAST argument as ; everything before it is the command to run. - If already inside tmux, 'connect' switches to the session; otherwise it attaches. - New tmux panes/windows created in a session always run inside the container. - within tmux that need : info, stop, rm, restore, connect EOF exit 1 } fail() { printf 'Error: %s\n' "$*" >&2 exit 1 } resolve_path() { local path="$1" if command -v realpath >/dev/null 2>&1; then realpath "$path" else echo "$(cd "$(dirname "$path")" && pwd)/$(basename "$path")" fi } docker_container_exists() { local name="$1" docker container ls -a --format '{{.Names}}' | grep -Fqx "$name" } docker_container_running() { local name="$1" docker container ls --format '{{.Names}}' | grep -Fqx "$name" } docker_image_present() { local ref="$1" docker image inspect "$ref" >/dev/null 2>&1 } cmd_create() { local image_arg="" project_arg="" while [[ $# -gt 0 ]]; do case "$1" in -i | --image) [[ $# -ge 2 ]] || fail "Missing value for $1" image_arg="$2" shift 2 ;; -p | --project) [[ $# -ge 2 ]] || fail "Missing value for $1" project_arg="$2" shift 2 ;; -*) usage ;; *) break ;; esac done # Check args local name_arg="${1:-}" if [[ -z "$name_arg" || -z "$image_arg" || -z "$project_arg" ]]; then fail "Missing arguments" fi # Check container name local cname="dev-$name_arg" if docker_container_exists "$cname"; then fail "Container already exists: "$cname" (from name "$name_arg")" fi # Check project path local project_path project_path="$(resolve_path "$project_arg")" if [[ ! -d "$project_path" ]]; then fail "Invalid project path: $project_path" fi # Check image IFS=' ' read -r image_ref image_repo image_tag image_label <<<"$(parse_image_ref "$image_arg")" if ! docker_image_present "$image_ref"; then fail $'Image not found locally.\nTry:\n\t- docker pull '"$image_ref" fi # Run (= create and start container) cmd=( docker run -d --name "$cname" --label dev=true --network host --init # run tini as PID 1 to handle signals & reap zombies for cleaner container shutdown -v "$project_path:/workspace" -v /var/run/docker.sock:/var/run/docker.sock ) [[ -d "$HOME/.ssh" ]] && cmd+=(-v "$HOME/.ssh:$CONTAINER_HOME/.ssh:ro") [[ -f "$HOME/.npmrc" ]] && cmd+=(-v "$HOME/.npmrc:$CONTAINER_HOME/.npmrc:ro") [[ -d "$HOME/.npm" ]] && cmd+=(-v "$HOME/.npm:$CONTAINER_HOME/.npm") docker_gid="$(getent group docker | cut -d: -f3 || true)" [[ -n "$docker_gid" ]] && cmd+=(--group-add "$docker_gid") cmd+=("$image_ref" sleep infinity) "${cmd[@]}" echo "$cname" } cmd_exec() { # usage: exec [-- ...] local name="$1" [[ -n "$name" ]] || fail "Missing project name" shift local cname="dev-$name" if ! docker_container_running "$cname"; then fail "Container $cname not running" fi if [[ "$1" == "--" ]]; then shift local args=("$@") if [[ -t 1 ]]; then docker exec -it "$cname" "${args[@]}" else docker exec "$cname" "${args[@]}" fi return fi # No command provided -> open a shell docker exec -it "$cname" zsh -l || docker exec -it "$cname" bash -l || docker exec -it "$cname" sh } cmd_connect() { # usage: connect [--from] local from_name="" while [[ $# -gt 0 ]]; do case "$1" in -f | --from) [[ $# -ge 2 ]] || fail "Missing value for $1" from_name="$2" shift 2 ;; -*) usage ;; *) break ;; esac done local name="${1:from_name}" [[ -n "$name" ]] || fail "Missing project name" local cname="dev-$name" if ! docker_container_exists "$cname"; then fail "Container does not exist: ${cname}. Run: dev create ..." fi if ! docker_container_running "$cname"; then docker start "$cname" >/dev/null fi if ! command -v tmux >/dev/null 2>&1; then echo "tmux not found; falling back to direct exec" exec "$0" exec "$name" fi local image_ref image_ref="$(docker container inspect "$cname" --format '{{ .Config.Image }}')" IFS=' ' read -r _image_ref image_repo image_tag image_label <<<"$(parse_image_ref "$image_ref")" local tname="dev:$name" if ! tmux has-session -t "$tname" 2>/dev/null; then tmux new-session -ds "$tname" -e "DEV_IMAGE=$image_label" "$0 exec \"$name\"" tmux set-option -t "$tname" default-command "$0 exec \"$name\"" fi if [[ -n "${TMUX-}" ]]; then tmux switch-client -t "$tname" else tmux attach -t "$tname" fi } shorten_project_path() { local project="$1" # Case 1: path is under PROJECT_DIR if [[ "$project" == "$PROJECT_DIR"* ]]; then project="~/$PROJECT_ABBR${project#$PROJECT_DIR}" # Case 2: path is under HOME (but not PROJECT_DIR) elif [[ "$project" == "$HOME"* ]]; then project="~${project#$HOME}" fi echo "$project" } cmd_list() { { echo "NAME|IMAGE|PROJECT|STATUS" docker ps -a --filter "label=dev=true" \ --format '{{.Label "dev.name"}}|{{.Image}}|{{.Label "dev.project_path"}}|{{.Status}}' } | while IFS='|' read -r fname image project status; do # Shorten registry prefix image="${image/$REGISTRY\//$REGISTRY_ABBR/}" # Shorten project path project="$(shorten_project_path "$project")" echo "$fname|$image|$project|$status" done | column -t -s '|' } cmd_stop() { local kill_flag=0 while [[ $# -gt 0 ]]; do case "$1" in --kill) kill_flag=1 shift ;; -*) usage ;; *) break ;; esac done local name="${1:-}" [[ -n "$name" ]] || fail "Missing project name" local cname="dev-$name" docker_container_exists "$cname" || fail "Container $cname does not exist" if ((kill_flag)); then echo "Killing container $cname..." docker kill "$cname" else echo "Stopping container $cname..." docker stop "$cname" fi } cmd_rm() { local force_flag=0 while [[ $# -gt 0 ]]; do case "$1" in --force | -f) force_flag=1 shift ;; -*) usage ;; *) break ;; esac done local name="${1:-}" [[ -n "$name" ]] || fail "Missing project name" local cname="dev-$name" docker_container_exists "$cname" || fail "Container $cname does not exist" if ((force_flag)); then echo "Removing container $cname (force)..." docker rm -f "$cname" else echo "Removing container $cname..." docker rm "$cname" fi } cmd_respawn() { local name="${1:-}" [[ -n "$name" ]] || fail "Missing project name" local cname="dev-$name" panes=$(tmux list-panes -t "$cname" -s -F "#{session_name}:#{window_index}.#{pane_index}") for pane in $panes; do echo "Respawning $pane..." tmux respawn-pane -t "$pane" done } cmd_test() { echo "Script dev is working fine!" } main() { local cmd="${1:-}" shift || true case "$cmd" in create) cmd_create "$@" ;; connect) cmd_connect "$@" ;; exec) cmd_exec "$@" ;; list) cmd_list ;; stop) cmd_stop "$@" ;; rm) cmd_rm "$@" ;; respawn) cmd_respawn "$@" ;; test) cmd_test "$@" ;; *) usage ;; esac } main "$@"