diff --git a/config/shared/bin/dev b/config/shared/bin/dev index 76ecd6d..cfd05c7 100755 --- a/config/shared/bin/dev +++ b/config/shared/bin/dev @@ -1,64 +1,327 @@ -#!/bin/bash - +#!/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() { - echo "Usage: $0 -i " + 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 } -# Parse arguments -while getopts ":i:" opt; do - case ${opt} in - i ) - IFS=':' read -r IMAGE IMAGE_TAG <<< "${OPTARG}" # Split image and tag - IMAGE_TAG=${IMAGE_TAG:-latest} # Default to 'latest' if no tag is provided - FULL_IMAGE_NAME="${REGISTRY}/${OPTARG}" # Keep the full image name with tag - ;; - \? ) - usage - ;; - esac -done - -shift "$((OPTIND -1))" - -if [ -z "$FULL_IMAGE_NAME" ] || [ -z "$1" ]; then - usage -fi - -NAME="$1" -CONTAINER_NAME="${IMAGE##*/}-${NAME}" # Use only the base image name (without tag) - -exec_into_container() { - docker exec --detach-keys "ctrl-q,ctrl-p" -it "$CONTAINER_NAME" bash -c "zsh" +fail() { + printf 'Error: %s\n' "$*" >&2 + exit 1 } -# Check if the container exists -if [ "$(docker ps -a -q -f "name=$CONTAINER_NAME")" ]; then - # Container exists, start it if it's not running - if [ ! "$(docker ps -q -f "name=$CONTAINER_NAME")" ]; then - echo "Container $CONTAINER_NAME exists but is not running. Starting it..." - docker start "$CONTAINER_NAME" +resolve_path() { + local path="$1" + if command -v realpath >/dev/null 2>&1; then + realpath "$path" else - echo "Container $CONTAINER_NAME is already running." + echo "$(cd "$(dirname "$path")" && pwd)/$(basename "$path")" fi -else - echo "Container $CONTAINER_NAME does not exist. Creating and running it in detached mode..." - docker run -d \ - --network host \ - -v "$HOME/.ssh:/home/dev/.ssh" \ - -v "$PWD:/workspace" \ - -v /var/run/docker.sock:/var/run/docker.sock \ - --group-add "$(getent group docker | cut -d: -f3)" \ - --name "$CONTAINER_NAME" \ - --init \ # run tini as PID 1 to handle signals & reap zombies for cleaner container shutdown - "$FULL_IMAGE_NAME" \ - sleep infinity # use if coreutils not available: tail -f /dev/null -fi +} -echo "Executing into container $CONTAINER_NAME..." -exec_into_container +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 "$@"