2026-10-06 23:11:07 -04:00
|
|
|
# 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}
|
2026-10-07 13:09:32 -04:00
|
|
|
# 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}
|
|
|
|
|
|
2026-10-11 01:01:51 -04:00
|
|
|
# The env files are read by systemd (EnvironmentFile=) as well, so they are
|
|
|
|
|
# parsed here the way systemd parses them, never sourced: KEY=value lines, the
|
|
|
|
|
# value taken literally (no $ expansion, no command substitution), optionally
|
|
|
|
|
# in matching quotes. Anything else is reported and skipped (devdesk#12).
|
2026-10-07 13:09:32 -04:00
|
|
|
dd_load_env() {
|
2026-10-11 01:01:51 -04:00
|
|
|
local _dd_f _dd_line _dd_n _dd_key _dd_val
|
|
|
|
|
for _dd_f in "$DEVDESK_ENV" "$DEVDESK_ENV_LOCAL"; do
|
|
|
|
|
[ -r "$_dd_f" ] || continue
|
|
|
|
|
_dd_n=0
|
|
|
|
|
while IFS= read -r _dd_line || [ -n "$_dd_line" ]; do
|
|
|
|
|
_dd_n=$((_dd_n + 1))
|
|
|
|
|
_dd_line=${_dd_line%$'\r'}
|
|
|
|
|
[[ $_dd_line =~ ^[[:space:]]*([#\;].*)?$ ]] && continue
|
|
|
|
|
if ! [[ $_dd_line =~ ^[[:space:]]*([A-Z_][A-Z0-9_]*)=(.*)$ ]]; then
|
|
|
|
|
printf 'devdesk: %s:%s: not a KEY=value line, ignored\n' "$_dd_f" "$_dd_n" >&2
|
|
|
|
|
continue
|
|
|
|
|
fi
|
|
|
|
|
_dd_key=${BASH_REMATCH[1]}
|
|
|
|
|
_dd_val=${BASH_REMATCH[2]}
|
|
|
|
|
_dd_val=${_dd_val%"${_dd_val##*[![:space:]]}"} # trailing blanks, like systemd
|
|
|
|
|
if [[ $_dd_val =~ ^\"(.*)\"$ || $_dd_val =~ ^\'(.*)\'$ ]]; then
|
|
|
|
|
_dd_val=${BASH_REMATCH[1]}
|
|
|
|
|
elif [[ $_dd_val == [\"\']* ]]; then
|
|
|
|
|
printf 'devdesk: %s:%s: unmatched quote, ignored\n' "$_dd_f" "$_dd_n" >&2
|
|
|
|
|
continue
|
|
|
|
|
fi
|
|
|
|
|
printf -v "$_dd_key" '%s' "$_dd_val"
|
|
|
|
|
done <"$_dd_f"
|
2026-10-07 13:09:32 -04:00
|
|
|
done
|
|
|
|
|
}
|
|
|
|
|
dd_load_env
|
2026-10-06 23:11:07 -04:00
|
|
|
|
|
|
|
|
: "${DEVDESK_PROJECTS_ROOT:=/root/code}"
|
2026-10-07 03:03:09 -04:00
|
|
|
: "${DEVDESK_DEFAULT_DIR:=$DEVDESK_PROJECTS_ROOT}"
|
2026-10-06 23:11:07 -04:00
|
|
|
: "${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}"
|
2026-10-07 13:09:32 -04:00
|
|
|
: "${DEVDESK_GITEA_URL:=https://code.lotusguild.org}"
|
|
|
|
|
: "${DEVDESK_GITEA_ALIASES:=10.10.10.63}"
|
|
|
|
|
: "${DEVDESK_GITEA_TOKEN_FILE:=/etc/devdesk/gitea-token}"
|
2026-10-07 19:26:06 -04:00
|
|
|
: "${DEVDESK_GRAFANA_URL:=}"
|
|
|
|
|
: "${DEVDESK_GRAFANA_PUBLIC_URL:=$DEVDESK_GRAFANA_URL}"
|
|
|
|
|
: "${DEVDESK_GRAFANA_TOKEN_FILE:=/etc/devdesk/grafana-token}"
|
2026-10-11 11:24:49 -04:00
|
|
|
: "${DEVDESK_PVE_NODES:=}"
|
2026-10-07 19:26:06 -04:00
|
|
|
: "${DEVDESK_UPLOAD_MAX:=26214400}"
|
2026-10-07 23:34:59 -04:00
|
|
|
: "${DEVDESK_PUBLIC_URL:=}"
|
|
|
|
|
: "${DEVDESK_MATRIX_WEBHOOK_FILE:=/etc/devdesk/matrix-webhook}"
|
2026-10-11 23:28:11 -04:00
|
|
|
: "${DEVDESK_MATRIX_DELAY:=60}"
|
|
|
|
|
: "${DEVDESK_MATRIX_QUIET:=600}"
|
|
|
|
|
: "${DEVDESK_USAGE_WARN:=90}"
|
2026-10-10 21:45:26 -04:00
|
|
|
: "${DEVDESK_PRESENCE_DIR:=/run/devdesk/presence}"
|
2026-10-06 23:11:07 -04:00
|
|
|
|
|
|
|
|
DEVDESK_REGISTRY="$DEVDESK_STATE_DIR/registry.json"
|
2026-10-07 03:03:09 -04:00
|
|
|
DEVDESK_CONTEXT_DIR="$DEVDESK_STATE_DIR/context"
|
2026-10-07 19:26:06 -04:00
|
|
|
DEVDESK_STATUS_DIR="$DEVDESK_STATE_DIR/state"
|
2026-10-06 23:11:07 -04:00
|
|
|
DEVDESK_NAME_RE='^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$'
|
|
|
|
|
|
2026-10-08 00:32:23 -04:00
|
|
|
# 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 23:11:07 -04:00
|
|
|
dd_die() {
|
|
|
|
|
printf 'devdesk: %s\n' "$*" >&2
|
2026-10-08 00:32:23 -04:00
|
|
|
dd_linger
|
2026-10-06 23:11:07 -04:00
|
|
|
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": {"<name>": {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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 01:01:51 -04:00
|
|
|
# dd_registry_locked CMD [ARGS...] — run CMD holding the registry lock, so a
|
|
|
|
|
# step that reads tmux and then writes the registry (the SessionStart hook,
|
|
|
|
|
# rename) can't interleave with another. Nests: an update inside reuses it.
|
|
|
|
|
dd_registry_locked() {
|
|
|
|
|
if [ -n "${DD_REGISTRY_LOCKED:-}" ]; then
|
|
|
|
|
"$@"
|
|
|
|
|
return
|
|
|
|
|
fi
|
2026-10-06 23:11:07 -04:00
|
|
|
mkdir -p "$DEVDESK_STATE_DIR"
|
|
|
|
|
(
|
|
|
|
|
flock -w 5 9 || exit 1
|
2026-10-11 01:01:51 -04:00
|
|
|
DD_REGISTRY_LOCKED=1
|
|
|
|
|
"$@"
|
2026-10-06 23:11:07 -04:00
|
|
|
) 9>"$DEVDESK_REGISTRY.lock"
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 01:01:51 -04:00
|
|
|
# dd_registry_update JQ_FILTER [jq args...] — read-modify-write under flock.
|
|
|
|
|
dd_registry_update() {
|
|
|
|
|
dd_registry_locked _dd_registry_apply "$@"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_dd_registry_apply() {
|
|
|
|
|
local filter=$1 tmp
|
|
|
|
|
shift
|
|
|
|
|
[ -s "$DEVDESK_REGISTRY" ] || printf '{"sessions":{}}\n' >"$DEVDESK_REGISTRY"
|
|
|
|
|
tmp=$(mktemp "$DEVDESK_REGISTRY.XXXXXX") || return 1
|
|
|
|
|
if jq "$@" "$filter" "$DEVDESK_REGISTRY" >"$tmp"; then
|
|
|
|
|
chmod 600 "$tmp"
|
|
|
|
|
mv -f "$tmp" "$DEVDESK_REGISTRY"
|
|
|
|
|
else
|
|
|
|
|
rm -f "$tmp"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Claude's conversation files: ~/.claude/projects/<folder>/<id>.jsonl.
|
|
|
|
|
dd_projects_dir() {
|
|
|
|
|
printf '%s/projects\n' "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dd_convo_file() {
|
|
|
|
|
[[ $1 =~ ^[0-9a-f-]{36}$ ]] || return 1
|
|
|
|
|
compgen -G "$(dd_projects_dir)/*/$1.jsonl" | head -n1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Temp files of writers that were killed mid-write (hooks run under Claude's
|
|
|
|
|
# timeouts): .<session>.XXXXXX and registry.json.XXXXXX, older than 10 minutes.
|
|
|
|
|
dd_sweep_tmp() {
|
|
|
|
|
find "$DEVDESK_CONTEXT_DIR" "$DEVDESK_STATUS_DIR" -maxdepth 1 -type f -name '.*.??????' -mmin +10 -delete 2>/dev/null
|
|
|
|
|
find "$DEVDESK_STATE_DIR" -maxdepth 1 -type f -name 'registry.json.??????' -mmin +10 -delete 2>/dev/null
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 23:11:07 -04:00
|
|
|
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"
|
2026-10-10 20:37:46 -04:00
|
|
|
rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json" "$DEVDESK_STATE_DIR/edits/$1.txt" \
|
2026-10-11 11:24:49 -04:00
|
|
|
"$DEVDESK_STATE_DIR/commands/$1.jsonl" "$DEVDESK_STATE_DIR/tags/$1.json" "$DEVDESK_STATE_DIR/prompts/$1.txt"
|
2026-10-06 23:11:07 -04:00
|
|
|
}
|
|
|
|
|
|
2026-10-07 03:03:09 -04:00
|
|
|
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"
|
2026-10-07 19:26:06 -04:00
|
|
|
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
|
2026-10-10 20:37:46 -04:00
|
|
|
local f
|
2026-10-11 11:24:49 -04:00
|
|
|
for f in "edits/%s.txt" "commands/%s.jsonl" "tags/%s.json" "prompts/%s.txt"; do
|
2026-10-10 20:37:46 -04:00
|
|
|
# shellcheck disable=SC2059 # the pattern is ours
|
|
|
|
|
if [ -f "$DEVDESK_STATE_DIR/$(printf "$f" "$1")" ]; then
|
|
|
|
|
# shellcheck disable=SC2059
|
|
|
|
|
mv -f "$DEVDESK_STATE_DIR/$(printf "$f" "$1")" "$DEVDESK_STATE_DIR/$(printf "$f" "$2")"
|
|
|
|
|
fi
|
|
|
|
|
done
|
2026-10-07 03:03:09 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Project name (directory or its slug) -> absolute directory inside the projects root.
|
|
|
|
|
dd_project_dir() {
|
2026-10-06 23:11:07 -04:00
|
|
|
local name=$1 dir="" d root
|
2026-10-07 03:03:09 -04:00
|
|
|
if [ -d "$DEVDESK_PROJECTS_ROOT/$name" ]; then
|
|
|
|
|
dir="$DEVDESK_PROJECTS_ROOT/$name"
|
2026-10-06 23:11:07 -04:00
|
|
|
else
|
2026-10-07 03:03:09 -04:00
|
|
|
for d in "$DEVDESK_PROJECTS_ROOT"/*/; do
|
|
|
|
|
d=${d%/}
|
|
|
|
|
if [ "$(dd_slugify "${d##*/}")" = "$name" ]; then
|
|
|
|
|
dir=$d
|
|
|
|
|
break
|
2026-10-06 23:11:07 -04:00
|
|
|
fi
|
2026-10-07 03:03:09 -04:00
|
|
|
done
|
2026-10-06 23:11:07 -04:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 19:26:06 -04:00
|
|
|
# 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}"
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 03:03:09 -04:00
|
|
|
# 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() {
|
2026-10-07 12:54:28 -04:00
|
|
|
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
|
2026-10-07 03:03:09 -04:00
|
|
|
name=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null)
|
|
|
|
|
fi
|
|
|
|
|
printf '%s\n' "${name:-${DEVDESK_SESSION:-}}"
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 23:11:07 -04:00
|
|
|
# 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"
|
|
|
|
|
}
|