This commit is contained in:
Tomas Mirchev 2025-10-04 01:23:18 +03:00
parent a6a97731af
commit 45f0f3cdc2

View File

@ -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 <image> <name>"
cat <<'EOF'
Usage: dev <command> [options] <name>
Commands:
create -i, --image <image> -p, --project <path> <name>
exec <name> [-- <cmd>...]
connect <name>
list
info <name>
stop [--kill] <name>
rm [--force|-f] <name>
Notes:
- 'exec' treats the LAST argument as <name>; 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 <name>: 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
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
;;
\? )
usage
-p | --project)
[[ $# -ge 2 ]] || fail "Missing value for $1"
project_arg="$2"
shift 2
;;
-*) usage ;;
*) break ;;
esac
done
shift "$((OPTIND -1))"
if [ -z "$FULL_IMAGE_NAME" ] || [ -z "$1" ]; then
usage
# Check args
local name_arg="${1:-}"
if [[ -z "$name_arg" || -z "$image_arg" || -z "$project_arg" ]]; then
fail "Missing arguments"
fi
NAME="$1"
CONTAINER_NAME="${IMAGE##*/}-${NAME}" # Use only the base image name (without tag)
# Check container name
local cname="dev-$name_arg"
if docker_container_exists "$cname"; then
fail "Container already exists: "$cname" (from name "$name_arg")"
fi
exec_into_container() {
docker exec --detach-keys "ctrl-q,ctrl-p" -it "$CONTAINER_NAME" bash -c "zsh"
# 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"
}
# 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"
else
echo "Container $CONTAINER_NAME is already running."
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
cmd_exec() {
# usage: exec <name> [-- <cmd>...]
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
echo "Executing into container $CONTAINER_NAME..."
exec_into_container
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] <name>
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 "$@"