feat: DevDesk v0.1.0, a persistent browser terminal for Claude Code
ttyd serves a single-file xterm.js UI styled with the LotusGuild TDS (web_template base.css). Sessions live in a dedicated tmux server unit (devdesk-tmux.service), so restarting or upgrading the web unit never stops Claude. Each named session maps to a project directory. A Claude SessionStart hook records the conversation ID, so after a reboot the session resumes that exact conversation. - bin/devdesk: attach / new / ls / end / pick, with an fzf picker - ttyd requires a credential that NPM injects; origin check enabled - debian/: dh 13 with --no-stop-on-upgrade for the tmux unit, bundled sha256-verified ttyd 1.7.7, lintian overrides, man page - CI: lint, test, build, smoke-test, artifact; v* tags publish to the Gitea apt registry and create a release Refs: tinker ticket 887531976 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
# 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}
|
||||
if [ -r "$DEVDESK_ENV" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
. "$DEVDESK_ENV"
|
||||
fi
|
||||
|
||||
: "${DEVDESK_PROJECTS_ROOT:=/root/code}"
|
||||
: "${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_REGISTRY="$DEVDESK_STATE_DIR/registry.json"
|
||||
DEVDESK_NAME_RE='^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$'
|
||||
|
||||
dd_die() {
|
||||
printf 'devdesk: %s\n' "$*" >&2
|
||||
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
|
||||
}
|
||||
|
||||
# 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"
|
||||
}
|
||||
|
||||
# Session name -> absolute project directory (must resolve inside the projects root).
|
||||
dd_resolve_dir() {
|
||||
local name=$1 dir="" d root
|
||||
if [ "$name" = "$DEVDESK_DEFAULT_SESSION" ]; then
|
||||
dir=$DEVDESK_PROJECTS_ROOT
|
||||
else
|
||||
dir=$(dd_registry_get "$name" dir)
|
||||
if [ -z "$dir" ] || [ ! -d "$dir" ]; then
|
||||
dir=""
|
||||
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
|
||||
fi
|
||||
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
|
||||
}
|
||||
|
||||
# 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"
|
||||
}
|
||||
Reference in New Issue
Block a user