Last active
April 29, 2026 06:30
-
-
Save riaf/53c67b5fb62c759013e084f0cd70502d to your computer and use it in GitHub Desktop.
Terminal-first Dev Container launcher for SSH/tmux workflows. Wraps devcontainer CLI with profile selection, shell/exec/rebuild helpers, SSH agent forwarding, and Tailscale Serve/Funnel shortcuts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| cmd="${1:-shell}" | |
| shift || true | |
| DEVX_FORWARD_SSH_AGENT="${DEVX_FORWARD_SSH_AGENT:-1}" | |
| DEVX_DEBUG="${DEVX_DEBUG:-0}" | |
| DEVX_UP_ARGS=() | |
| DEVX_EXEC_ENV=( | |
| "LANG=C.UTF-8" | |
| "LC_ALL=C.UTF-8" | |
| "LC_CTYPE=C.UTF-8" | |
| ) | |
| DEVX_AGENT_DIR="" | |
| DEVX_AGENT_SOCK="" | |
| DEVX_AGENT_PID="" | |
| log() { | |
| echo "devx: $*" >&2 | |
| } | |
| debug() { | |
| if [[ "$DEVX_DEBUG" == "1" ]]; then | |
| echo "devx debug: $*" >&2 | |
| fi | |
| } | |
| die() { | |
| echo "devx: $*" >&2 | |
| exit 1 | |
| } | |
| require_command() { | |
| command -v "$1" >/dev/null 2>&1 || die "$1 が見つかりません" | |
| } | |
| find_workspace() { | |
| local dir="$PWD" | |
| while [[ "$dir" != "/" ]]; do | |
| if [[ -f "$dir/devcontainer.json" ]]; then | |
| printf '%s\n' "$dir" | |
| return 0 | |
| fi | |
| if [[ -f "$dir/.devcontainer/devcontainer.json" ]]; then | |
| printf '%s\n' "$dir" | |
| return 0 | |
| fi | |
| if [[ -d "$dir/.devcontainer" ]]; then | |
| if find "$dir/.devcontainer" -mindepth 2 -maxdepth 2 -name devcontainer.json -print -quit | grep -q .; then | |
| printf '%s\n' "$dir" | |
| return 0 | |
| fi | |
| fi | |
| dir="$(dirname "$dir")" | |
| done | |
| die "devcontainer configuration が見つかりません: $PWD" | |
| } | |
| workspace="$(find_workspace)" | |
| list_configs() { | |
| local found=0 | |
| if [[ -f "$workspace/devcontainer.json" ]]; then | |
| printf 'root\t%s\n' "$workspace/devcontainer.json" | |
| found=1 | |
| fi | |
| if [[ -f "$workspace/.devcontainer/devcontainer.json" ]]; then | |
| printf 'default\t%s\n' "$workspace/.devcontainer/devcontainer.json" | |
| found=1 | |
| fi | |
| if [[ -d "$workspace/.devcontainer" ]]; then | |
| while IFS= read -r file; do | |
| local parent | |
| parent="$(basename "$(dirname "$file")")" | |
| if [[ "$file" == "$workspace/.devcontainer/devcontainer.json" ]]; then | |
| continue | |
| fi | |
| printf '%s\t%s\n' "$parent" "$file" | |
| found=1 | |
| done < <(find "$workspace/.devcontainer" -mindepth 2 -maxdepth 2 -name devcontainer.json | sort) | |
| fi | |
| [[ "$found" -eq 1 ]] | |
| } | |
| select_config() { | |
| local requested="${1:-}" | |
| local configs | |
| configs="$(list_configs)" || die "devcontainer.json が見つかりません: $workspace" | |
| if [[ -n "$requested" ]]; then | |
| local matched | |
| matched="$(printf '%s\n' "$configs" | awk -F '\t' -v name="$requested" '$1 == name { print $2 }')" | |
| if [[ -z "$matched" ]]; then | |
| echo "利用可能な devcontainer:" >&2 | |
| printf '%s\n' "$configs" | awk -F '\t' '{ print " " $1 }' >&2 | |
| die "指定された devcontainer が見つかりません: $requested" | |
| fi | |
| printf '%s\n' "$matched" | |
| return 0 | |
| fi | |
| local count | |
| count="$(printf '%s\n' "$configs" | wc -l | tr -d ' ')" | |
| if [[ "$count" -eq 1 ]]; then | |
| printf '%s\n' "$configs" | awk -F '\t' '{ print $2 }' | |
| return 0 | |
| fi | |
| if command -v fzf >/dev/null 2>&1; then | |
| printf '%s\n' "$configs" \ | |
| | fzf --prompt='devcontainer> ' --with-nth=1 \ | |
| | awk -F '\t' '{ print $2 }' | |
| return 0 | |
| fi | |
| echo "複数の devcontainer があります。名前を指定してください:" >&2 | |
| printf '%s\n' "$configs" | awk -F '\t' '{ print " " $1 }' >&2 | |
| die "例: devx shell backend-api" | |
| } | |
| config_name_from_path() { | |
| local config="$1" | |
| if [[ "$config" == "$workspace/devcontainer.json" ]]; then | |
| echo "root" | |
| elif [[ "$config" == "$workspace/.devcontainer/devcontainer.json" ]]; then | |
| echo "default" | |
| else | |
| basename "$(dirname "$config")" | |
| fi | |
| } | |
| hash_for_config() { | |
| local config="$1" | |
| local name | |
| name="$(printf '%s' "${workspace}:${config}" | sha1sum | awk '{ print $1 }' | cut -c1-12)" | |
| printf '%s\n' "$name" | |
| } | |
| ensure_devcontainer_mount_supported() { | |
| if ! devcontainer up --help 2>&1 | grep -q -- '--mount'; then | |
| die "この devcontainer CLI は 'devcontainer up --mount' に対応していないようです。devcontainer CLI の更新が必要です。" | |
| fi | |
| } | |
| setup_ssh_agent_forwarding() { | |
| local config="$1" | |
| DEVX_UP_ARGS=() | |
| DEVX_EXEC_ENV=( | |
| "LANG=C.UTF-8" | |
| "LC_ALL=C.UTF-8" | |
| "LC_CTYPE=C.UTF-8" | |
| ) | |
| if [[ "$DEVX_FORWARD_SSH_AGENT" != "1" ]]; then | |
| debug "SSH agent forwarding disabled" | |
| return 0 | |
| fi | |
| if [[ -z "${SSH_AUTH_SOCK:-}" ]]; then | |
| log "SSH_AUTH_SOCK がないため SSH agent forwarding は無効です" | |
| return 0 | |
| fi | |
| if [[ ! -S "${SSH_AUTH_SOCK:-}" ]]; then | |
| log "SSH_AUTH_SOCK が socket ではないため SSH agent forwarding は無効です: ${SSH_AUTH_SOCK}" | |
| return 0 | |
| fi | |
| require_command socat | |
| ensure_devcontainer_mount_supported | |
| local uid | |
| uid="$(id -u)" | |
| local key | |
| key="$(hash_for_config "$config")" | |
| DEVX_AGENT_DIR="/tmp/devx-ssh-agent-${uid}" | |
| DEVX_AGENT_SOCK="${DEVX_AGENT_DIR}/agent-${key}.sock" | |
| DEVX_AGENT_PID="${DEVX_AGENT_DIR}/agent-${key}.pid" | |
| mkdir -p "$DEVX_AGENT_DIR" | |
| chmod 700 "$DEVX_AGENT_DIR" | |
| if [[ -f "$DEVX_AGENT_PID" ]]; then | |
| local old_pid | |
| old_pid="$(cat "$DEVX_AGENT_PID" 2>/dev/null || true)" | |
| if [[ -n "$old_pid" ]] && kill -0 "$old_pid" 2>/dev/null; then | |
| debug "kill old socat pid: $old_pid" | |
| kill "$old_pid" 2>/dev/null || true | |
| fi | |
| fi | |
| rm -f "$DEVX_AGENT_SOCK" | |
| debug "start socat relay: ${DEVX_AGENT_SOCK} -> ${SSH_AUTH_SOCK}" | |
| socat \ | |
| "UNIX-LISTEN:${DEVX_AGENT_SOCK},fork,mode=0600" \ | |
| "UNIX-CONNECT:${SSH_AUTH_SOCK}" \ | |
| >/dev/null 2>&1 & | |
| echo "$!" > "$DEVX_AGENT_PID" | |
| # socket 作成を少し待つ | |
| local i | |
| for i in {1..20}; do | |
| if [[ -S "$DEVX_AGENT_SOCK" ]]; then | |
| break | |
| fi | |
| sleep 0.05 | |
| done | |
| if [[ ! -S "$DEVX_AGENT_SOCK" ]]; then | |
| die "socat relay socket を作成できませんでした: $DEVX_AGENT_SOCK" | |
| fi | |
| # socket 自体ではなく、親ディレクトリを mount する。 | |
| # これにより、同じ path の socket を作り直しても container 側から見える。 | |
| DEVX_UP_ARGS+=( | |
| --mount "type=bind,source=${DEVX_AGENT_DIR},target=${DEVX_AGENT_DIR}" | |
| ) | |
| DEVX_EXEC_ENV+=( | |
| "SSH_AUTH_SOCK=${DEVX_AGENT_SOCK}" | |
| ) | |
| debug "mount arg: --mount type=bind,source=${DEVX_AGENT_DIR},target=${DEVX_AGENT_DIR}" | |
| debug "container SSH_AUTH_SOCK: ${DEVX_AGENT_SOCK}" | |
| } | |
| run_devcontainer_up() { | |
| local config="$1" | |
| shift || true | |
| debug "devcontainer up --workspace-folder $workspace --config $config ${DEVX_UP_ARGS[*]} $*" | |
| devcontainer up \ | |
| --workspace-folder "$workspace" \ | |
| --config "$config" \ | |
| "${DEVX_UP_ARGS[@]}" \ | |
| "$@" | |
| } | |
| run_devcontainer_exec() { | |
| local config="$1" | |
| shift | |
| local env_cmd=(env) | |
| local e | |
| for e in "${DEVX_EXEC_ENV[@]}"; do | |
| env_cmd+=("$e") | |
| done | |
| debug "devcontainer exec --workspace-folder $workspace --config $config ${env_cmd[*]} $*" | |
| devcontainer exec \ | |
| --workspace-folder "$workspace" \ | |
| --config "$config" \ | |
| "${env_cmd[@]}" \ | |
| "$@" | |
| } | |
| enter_default_shell() { | |
| local config="$1" | |
| run_devcontainer_exec "$config" sh -lc ' | |
| if command -v zsh >/dev/null 2>&1; then | |
| exec zsh -l | |
| elif command -v bash >/dev/null 2>&1; then | |
| exec bash -l | |
| else | |
| exec sh -l | |
| fi | |
| ' | |
| } | |
| show_doctor() { | |
| echo "workspace: $workspace" | |
| echo | |
| echo "configs:" | |
| list_configs | awk -F '\t' '{ print " " $1 " -> " $2 }' | |
| echo | |
| echo "host:" | |
| echo " user: $(id -un)" | |
| echo " uid: $(id -u)" | |
| echo " SSH_AUTH_SOCK: ${SSH_AUTH_SOCK:-}" | |
| if [[ -n "${SSH_AUTH_SOCK:-}" && -S "${SSH_AUTH_SOCK:-}" ]]; then | |
| echo " SSH_AUTH_SOCK exists: yes" | |
| else | |
| echo " SSH_AUTH_SOCK exists: no" | |
| fi | |
| echo | |
| echo "commands:" | |
| if command -v devcontainer >/dev/null 2>&1; then | |
| echo " devcontainer: $(command -v devcontainer)" | |
| if devcontainer up --help 2>&1 | grep -q -- '--mount'; then | |
| echo " devcontainer up --mount: yes" | |
| else | |
| echo " devcontainer up --mount: no" | |
| fi | |
| else | |
| echo " devcontainer: missing" | |
| fi | |
| if command -v socat >/dev/null 2>&1; then | |
| echo " socat: $(command -v socat)" | |
| else | |
| echo " socat: missing" | |
| fi | |
| if command -v jq >/dev/null 2>&1; then | |
| echo " jq: $(command -v jq)" | |
| else | |
| echo " jq: missing" | |
| fi | |
| if command -v fzf >/dev/null 2>&1; then | |
| echo " fzf: $(command -v fzf)" | |
| else | |
| echo " fzf: missing" | |
| fi | |
| } | |
| require_command devcontainer | |
| case "$cmd" in | |
| list|ls) | |
| list_configs | awk -F '\t' '{ print $1 }' | |
| ;; | |
| path) | |
| echo "$workspace" | |
| ;; | |
| config) | |
| profile="${1:-}" | |
| config="$(select_config "$profile")" | |
| echo "$config" | |
| ;; | |
| doctor) | |
| show_doctor | |
| ;; | |
| up) | |
| profile="${1:-}" | |
| config="$(select_config "$profile")" | |
| setup_ssh_agent_forwarding "$config" | |
| log "up $(config_name_from_path "$config")" | |
| run_devcontainer_up "$config" | |
| ;; | |
| rebuild) | |
| profile="${1:-}" | |
| config="$(select_config "$profile")" | |
| setup_ssh_agent_forwarding "$config" | |
| log "rebuild $(config_name_from_path "$config")" | |
| run_devcontainer_up "$config" --remove-existing-container | |
| ;; | |
| shell|sh) | |
| profile="${1:-}" | |
| config="$(select_config "$profile")" | |
| setup_ssh_agent_forwarding "$config" | |
| log "shell $(config_name_from_path "$config")" | |
| run_devcontainer_up "$config" | |
| enter_default_shell "$config" | |
| ;; | |
| exec) | |
| profile="${1:-}" | |
| [[ -n "$profile" ]] || die "usage: devx exec <profile> -- <command...>" | |
| shift || true | |
| if [[ "${1:-}" == "--" ]]; then | |
| shift | |
| fi | |
| [[ "$#" -gt 0 ]] || die "usage: devx exec <profile> -- <command...>" | |
| config="$(select_config "$profile")" | |
| setup_ssh_agent_forwarding "$config" | |
| run_devcontainer_up "$config" | |
| run_devcontainer_exec "$config" "$@" | |
| ;; | |
| serve) | |
| port="${1:-18000}" | |
| tailscale serve "localhost:${port}" | |
| ;; | |
| serve-off) | |
| tailscale serve off | |
| ;; | |
| funnel) | |
| port="${1:-18000}" | |
| tailscale funnel "localhost:${port}" | |
| ;; | |
| funnel-off) | |
| tailscale funnel off | |
| ;; | |
| url) | |
| require_command jq | |
| host="$(tailscale status --json | jq -r '.Self.DNSName // empty' | sed 's/\.$//')" | |
| [[ -n "$host" ]] || die "Tailscale DNSName を取得できませんでした" | |
| echo "https://${host}" | |
| ;; | |
| *) | |
| cat >&2 <<EOF | |
| usage: | |
| devx list | |
| devx path | |
| devx config [profile] | |
| devx doctor | |
| devx up [profile] | |
| devx rebuild [profile] | |
| devx shell [profile] | |
| devx exec <profile> -- <command...> | |
| devx serve [host_port] | |
| devx serve-off | |
| devx funnel [host_port] | |
| devx funnel-off | |
| devx url | |
| examples: | |
| devx shell backend-api | |
| devx shell client-app | |
| devx exec backend-api -- ssh-add -L | |
| DEVX_DEBUG=1 devx rebuild backend-api | |
| DEVX_FORWARD_SSH_AGENT=0 devx shell backend-api | |
| EOF | |
| exit 2 | |
| ;; | |
| esac |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment