Skip to content

Instantly share code, notes, and snippets.

@riaf
Last active April 29, 2026 06:30
Show Gist options
  • Select an option

  • Save riaf/53c67b5fb62c759013e084f0cd70502d to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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