# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables ($n, $d, …) # Shared helpers for the devdesk CLI and its libexec scripts. Sourced, not executed. # # Every path can be overridden from the environment so the test suite can run # against a throwaway tmux server and state directory. DEVDESK_ENV=${DEVDESK_ENV:-/etc/devdesk/env} # Local overrides go in env.local: it is never shipped, so upgrades don't prompt about it. DEVDESK_ENV_LOCAL=${DEVDESK_ENV_LOCAL:-${DEVDESK_ENV}.local} dd_load_env() { local f for f in "$DEVDESK_ENV" "$DEVDESK_ENV_LOCAL"; do if [ -r "$f" ]; then # shellcheck source=/dev/null . "$f" fi done } dd_load_env : "${DEVDESK_PROJECTS_ROOT:=/root/code}" : "${DEVDESK_DEFAULT_DIR:=$DEVDESK_PROJECTS_ROOT}" : "${DEVDESK_DEFAULT_SESSION:=main}" : "${DEVDESK_SOCKET:=/run/devdesk/tmux.sock}" : "${DEVDESK_STATE_DIR:=/var/lib/devdesk}" : "${DEVDESK_LIBEXEC:=/usr/lib/devdesk}" : "${DEVDESK_SHARE:=/usr/share/devdesk}" : "${DEVDESK_TMUX_UNIT:=devdesk-tmux.service}" : "${DEVDESK_GITEA_URL:=https://code.lotusguild.org}" : "${DEVDESK_GITEA_ALIASES:=10.10.10.63}" : "${DEVDESK_GITEA_TOKEN_FILE:=/etc/devdesk/gitea-token}" : "${DEVDESK_GRAFANA_URL:=}" : "${DEVDESK_GRAFANA_PUBLIC_URL:=$DEVDESK_GRAFANA_URL}" : "${DEVDESK_GRAFANA_TOKEN_FILE:=/etc/devdesk/grafana-token}" : "${DEVDESK_UPLOAD_MAX:=26214400}" : "${DEVDESK_PUBLIC_URL:=}" : "${DEVDESK_MATRIX_WEBHOOK_FILE:=/etc/devdesk/matrix-webhook}" : "${DEVDESK_MATRIX_DELAY:=30}" DEVDESK_REGISTRY="$DEVDESK_STATE_DIR/registry.json" DEVDESK_CONTEXT_DIR="$DEVDESK_STATE_DIR/context" DEVDESK_STATUS_DIR="$DEVDESK_STATE_DIR/state" DEVDESK_NAME_RE='^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$' # Under ttyd, a command that exits right after printing sometimes loses its last # output: ttyd sees the exit and closes the WebSocket before reading the pty # (devdesk#35). One-shot replies and errors therefore wait, at most # DEVDESK_LINGER seconds, for the browser to hang up once it has the answer # (ttyd then sends SIGHUP). devdesk-serve sets DEVDESK_VIA_TTYD=1. dd_linger() { [ "${DEVDESK_VIA_TTYD:-}" = 1 ] && [ -t 0 ] || return 0 read -r -t "${DEVDESK_LINGER:-1}" _ 2>/dev/null || true } dd_die() { printf 'devdesk: %s\n' "$*" >&2 dd_linger exit 1 } # tmux client bound to the DevDesk server. -N: never start a server from here, # because a server started by a client would live in the caller's cgroup (e.g. # ttyd's) and die with it. dd_tmux() { tmux -N -u -S "$DEVDESK_SOCKET" "$@" } dd_valid_name() { [[ $1 =~ $DEVDESK_NAME_RE ]] } # Directory name -> session name: anything outside [A-Za-z0-9_-] becomes '-', # leading separators are dropped, max 64 chars. dd_slugify() { local s=${1//[^A-Za-z0-9_-]/-} while [[ $s == [-_]* ]]; do s=${s:1}; done printf '%s' "${s:0:64}" } dd_server_up() { [ -S "$DEVDESK_SOCKET" ] && dd_tmux start-server 2>/dev/null } dd_ensure_server() { dd_server_up && return 0 if [ -z "${DEVDESK_NO_SYSTEMD:-}" ] && [ -d /run/systemd/system ]; then # ExecStartPost on the unit blocks until the socket answers. systemctl start "$DEVDESK_TMUX_UNIT" || dd_die "could not start $DEVDESK_TMUX_UNIT (see: journalctl -u $DEVDESK_TMUX_UNIT)" dd_server_up && return 0 fi dd_die "tmux server is not running on $DEVDESK_SOCKET (start $DEVDESK_TMUX_UNIT)" } # --- registry: $DEVDESK_REGISTRY = {"sessions": {"": {dir, claude_session_id, ...}}} dd_registry_get() { [ -s "$DEVDESK_REGISTRY" ] || return 0 jq -Mr --arg n "$1" --arg f "$2" '.sessions[$n][$f] // empty' "$DEVDESK_REGISTRY" 2>/dev/null } # dd_registry_update JQ_FILTER [jq args...] — read-modify-write under flock. dd_registry_update() { local filter=$1 shift mkdir -p "$DEVDESK_STATE_DIR" ( flock -w 5 9 || exit 1 [ -s "$DEVDESK_REGISTRY" ] || printf '{"sessions":{}}\n' >"$DEVDESK_REGISTRY" local tmp tmp=$(mktemp "$DEVDESK_REGISTRY.XXXXXX") || exit 1 if jq "$@" "$filter" "$DEVDESK_REGISTRY" >"$tmp"; then chmod 600 "$tmp" mv -f "$tmp" "$DEVDESK_REGISTRY" else rm -f "$tmp" exit 1 fi ) 9>"$DEVDESK_REGISTRY.lock" } dd_registry_set_dir() { dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {dir: $d, updated: $t})' \ --arg n "$1" --arg d "$2" --arg t "$(date -Is)" } dd_registry_set_claude() { dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {claude_session_id: $id, claude_source: $src, updated: $t})' \ --arg n "$1" --arg id "$2" --arg src "$3" --arg t "$(date -Is)" } dd_registry_remove() { dd_registry_update 'del(.sessions[$n])' --arg n "$1" rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json" "$DEVDESK_STATE_DIR/edits/$1.txt" } dd_registry_rename() { dd_registry_update 'if .sessions[$old] then .sessions[$new] = .sessions[$old] | del(.sessions[$old]) else . end' \ --arg old "$1" --arg new "$2" local d for d in "$DEVDESK_CONTEXT_DIR" "$DEVDESK_STATUS_DIR"; do if [ -f "$d/$1.json" ]; then mv -f "$d/$1.json" "$d/$2.json" fi done if [ -f "$DEVDESK_STATE_DIR/edits/$1.txt" ]; then mv -f "$DEVDESK_STATE_DIR/edits/$1.txt" "$DEVDESK_STATE_DIR/edits/$2.txt" fi } # Project name (directory or its slug) -> absolute directory inside the projects root. dd_project_dir() { local name=$1 dir="" d root if [ -d "$DEVDESK_PROJECTS_ROOT/$name" ]; then dir="$DEVDESK_PROJECTS_ROOT/$name" else for d in "$DEVDESK_PROJECTS_ROOT"/*/; do d=${d%/} if [ "$(dd_slugify "${d##*/}")" = "$name" ]; then dir=$d break fi done fi [ -n "$dir" ] || return 1 dir=$(realpath -e -- "$dir" 2>/dev/null) || return 1 root=$(realpath -e -- "$DEVDESK_PROJECTS_ROOT" 2>/dev/null) || return 1 case "$dir/" in "$root"/*) printf '%s\n' "$dir" ;; *) return 1 ;; esac } # Upload filename -> safe basename ([A-Za-z0-9._-], no leading dots, max 100 chars). dd_safe_filename() { local n=${1##*/} n=${n//[^A-Za-z0-9._-]/_} while [[ $n == .* ]]; do n=${n:1}; done n=${n:0:100} printf '%s' "${n:-upload}" } # Session name -> working directory. Sessions don't need a project: the # registry wins, then a project of the same name, then DEVDESK_DEFAULT_DIR. dd_resolve_dir() { local name=$1 dir dir=$(dd_registry_get "$name" dir) if [ -n "$dir" ] && [ -d "$dir" ]; then printf '%s\n' "$dir" elif [ "$name" != "$DEVDESK_DEFAULT_SESSION" ] && dir=$(dd_project_dir "$name"); then printf '%s\n' "$dir" else realpath -e -- "$DEVDESK_DEFAULT_DIR" 2>/dev/null || printf '%s\n' "$HOME" fi } # Name of the DevDesk session this process runs in. Asks tmux, so it stays # right after a rename; falls back to the DEVDESK_SESSION set at creation. dd_current_session() { local name="" outer=${TMUX:-} # Only DevDesk's own tmux server: a Claude started with these settings in # some other tmux must not write into DevDesk's state. if [ "${outer%%,*}" = "$DEVDESK_SOCKET" ] && [ -n "${TMUX_PANE:-}" ]; then name=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null) fi printf '%s\n' "${name:-${DEVDESK_SESSION:-}}" } # Arguments for `tmux new-session` that create session NAME in DIR running Claude. dd_new_session_args() { printf '%s\0' -s "$1" -c "$2" -n claude -e "DEVDESK_SESSION=$1" "$DEVDESK_LIBEXEC/devdesk-claude-run" }