diff --git a/README.md b/README.md index 1adf777..7b2ec1d 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,10 @@ The tmux server runs in its own unit, so every Claude process lives in **that** Use the **Full** button for fullscreen. In Chromium it also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves. ## Configuration -- `/etc/devdesk/env` (conffile): +- **Put your changes in `/etc/devdesk/env.local`.** It's read after the packaged `env` and never shipped, so `apt upgrade` never asks about it. Example: `DEVDESK_REMOTE_CONTROL=1`. +- `/etc/devdesk/env` holds the packaged defaults; don't edit it. Its keys: - port, bind address, projects root and `DEVDESK_DEFAULT_DIR` + - `DEVDESK_GITEA_URL` / `DEVDESK_GITEA_ALIASES` for the Repo/CI panel. Public repos need no token; for private ones put a `read:repository` token in `/etc/devdesk/gitea-token` - `CLAUDE_BIN` and `CLAUDE_ARGS` - `DEVDESK_REMOTE_CONTROL=1`, which also starts each session with Claude Remote Control so it can be continued from the Claude app - `/etc/devdesk/secret.env` is generated on install. It holds the ttyd credential that NPM must inject; see [docs/DEPLOY.md](docs/DEPLOY.md). diff --git a/bin/devdesk b/bin/devdesk index f4b5ef0..3ed40fc 100755 --- a/bin/devdesk +++ b/bin/devdesk @@ -5,10 +5,11 @@ # devdesk new NAME [PROJECT] create session NAME (detached) in PROJECT's folder, or the default folder # devdesk rename OLD NEW rename a session (running or dormant) # devdesk end NAME stop session NAME and forget it +# devdesk repo NAME git + Gitea CI status of the session's folder (JSON) # devdesk ls list sessions # devdesk pick interactive picker # -# The web UI calls `devdesk attach --list | --new NAME [PROJECT] | --rename OLD NEW | --end NAME` +# The web UI calls `devdesk attach --list | --repo NAME | --new NAME [PROJECT] | --rename OLD NEW | --end NAME` # through ttyd; those print JSON. set -euo pipefail @@ -16,7 +17,7 @@ set -euo pipefail . "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" usage() { - sed -n '2,10s/^# \{0,1\}//p' "$0" + sed -n '2,11s/^# \{0,1\}//p' "$0" } # Tab-separated running sessions: name, dir, attached clients, last activity, windows. @@ -144,6 +145,67 @@ cmd_end() { dd_registry_remove "$1" } +# GET $DEVDESK_GITEA_URL/api/v1; token optional (private repos only). +gitea_get() { + local auth=() + if [ -r "$DEVDESK_GITEA_TOKEN_FILE" ]; then + auth=(-H "Authorization: token $(tr -d '[:space:]' <"$DEVDESK_GITEA_TOKEN_FILE")") + fi + curl -fsS --max-time 4 "${auth[@]}" "$DEVDESK_GITEA_URL/api/v1$1" 2>/dev/null +} + +# owner/repo when REMOTE points at our Gitea (https or ssh, any configured host), else nothing. +gitea_slug() { + local remote=$1 host hosts + hosts="${DEVDESK_GITEA_URL#*://} $DEVDESK_GITEA_ALIASES" + for host in $hosts; do + host=${host%%/*} + host=${host%%:*} + if [[ $remote =~ [@/]${host//./\.}(:[0-9]+)?[:/]+([^/]+/[^/]+)$ ]]; then + printf '%s\n' "${BASH_REMATCH[2]%.git}" + return + fi + done +} + +cmd_repo() { + [ $# -eq 1 ] || dd_die "usage: devdesk repo NAME" + dd_valid_name "$1" || dd_die "invalid session name: $1" + local dir g branch sha="" dirty ahead=null behind=null upstream=false remote slug="" ci=null prs=null + dir=$(dd_resolve_dir "$1") + g=(git -c safe.directory='*' -C "$dir") + if ! "${g[@]}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + jq -cMn --arg s "$1" --arg dir "$dir" '{session: $s, dir: $dir, git: false}' + return + fi + branch=$("${g[@]}" symbolic-ref --short -q HEAD || echo "(detached)") + sha=$("${g[@]}" rev-parse -q --verify HEAD 2>/dev/null || true) + dirty=$("${g[@]}" status --porcelain 2>/dev/null | wc -l) + if "${g[@]}" rev-parse -q --verify '@{u}' >/dev/null 2>&1; then + upstream=true + read -r behind ahead < <("${g[@]}" rev-list --left-right --count '@{u}...HEAD') + fi + remote=$("${g[@]}" remote get-url origin 2>/dev/null || true) + [ -n "$remote" ] && slug=$(gitea_slug "$remote") + if [ -n "$slug" ] && [ -n "$sha" ]; then + ci=$(gitea_get "/repos/$slug/commits/$sha/status" | + jq -cM '{state, total: .total_count, + url: (.statuses[0]?.target_url | if . then sub("/jobs/[0-9]+$"; "") else null end), + jobs: [.statuses[]? | {name: .context, status}]}' 2>/dev/null) || ci=null + prs=$(gitea_get "/repos/$slug/pulls?state=open&limit=50" | jq -M 'length' 2>/dev/null) || prs=null + fi + jq -cMn --arg s "$1" --arg dir "$dir" --arg branch "$branch" --arg sha "${sha:0:7}" \ + --argjson dirty "$dirty" --argjson ahead "$ahead" --argjson behind "$behind" \ + --argjson upstream "$upstream" --arg remote "$remote" --arg slug "$slug" \ + --arg web "$DEVDESK_GITEA_URL" --argjson ci "${ci:-null}" --argjson prs "${prs:-null}" ' + {session: $s, dir: $dir, git: true, branch: $branch, sha: $sha, dirty: $dirty, + ahead: $ahead, behind: $behind, upstream: $upstream, remote: $remote, + repo: (if $slug == "" then null else $slug end), + url: (if $slug == "" then null else "\($web)/\($slug)" end), + ci: (if $ci == null then null else $ci + {url: (if $ci.url then $web + $ci.url else null end)} end), + prs: $prs}' +} + ok_json() { printf '{"ok":true}\n' } @@ -158,6 +220,11 @@ cmd_attach() { cmd_list return ;; + --repo) + shift + cmd_repo "$@" + return + ;; --new | --rename | --end) local action=${1#--} shift @@ -195,6 +262,7 @@ case $cmd in attach | a) cmd_attach "$@" ;; new) cmd_new "$@" ;; rename | mv) cmd_rename "$@" ;; +repo) cmd_repo "$@" ;; ls | list) cmd_ls ;; --list) cmd_list ;; end) cmd_end "$@" ;; diff --git a/debian/changelog b/debian/changelog index 2cada41..a7a9235 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,17 @@ +devdesk (0.3.0) bookworm; urgency=medium + + * Repo / CI panel is live: branch, ahead/behind, uncommitted changes, the + Gitea CI status of HEAD (linked to the run) and open PRs for the current + session's folder, via `devdesk attach --repo` over the ttyd channel. No + sidecar needed; public repos need no token (optional + /etc/devdesk/gitea-token for private ones). A notification fires when CI + for HEAD finishes while the tab is in the background. + * Remote Control verified with resume (same claude.ai session link). + * Local settings now go in /etc/devdesk/env.local (read after the packaged + env, never shipped), so upgrades no longer stop at a conffile prompt. + + -- Jared Vititoe Wed, 07 Oct 2026 12:57:04 -0400 + devdesk (0.2.1) bookworm; urgency=medium * Session hook and status line only record for panes on DevDesk's own diff --git a/debian/devdesk.devdesk-tmux.service b/debian/devdesk.devdesk-tmux.service index 6c7d1ca..4d513f6 100644 --- a/debian/devdesk.devdesk-tmux.service +++ b/debian/devdesk.devdesk-tmux.service @@ -11,6 +11,7 @@ User=root Environment=HOME=/root USER=root LOGNAME=root SHELL=/bin/bash LANG=C.UTF-8 Environment=PATH=/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin EnvironmentFile=-/etc/devdesk/env +EnvironmentFile=-/etc/devdesk/env.local WorkingDirectory=-/root RuntimeDirectory=devdesk RuntimeDirectoryMode=0700 diff --git a/debian/devdesk.postrm b/debian/devdesk.postrm index d4e6f30..bc0309f 100644 --- a/debian/devdesk.postrm +++ b/debian/devdesk.postrm @@ -2,7 +2,7 @@ set -e if [ "$1" = purge ]; then - rm -f /etc/devdesk/secret.env + rm -f /etc/devdesk/secret.env /etc/devdesk/env.local rm -rf /var/lib/devdesk fi diff --git a/debian/devdesk.service b/debian/devdesk.service index 8a6fe06..b66f15a 100644 --- a/debian/devdesk.service +++ b/debian/devdesk.service @@ -10,6 +10,7 @@ User=root Environment=HOME=/root LANG=C.UTF-8 Environment=PATH=/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin EnvironmentFile=/etc/devdesk/env +EnvironmentFile=-/etc/devdesk/env.local EnvironmentFile=/etc/devdesk/secret.env ExecStart=/usr/lib/devdesk/devdesk-serve Restart=always diff --git a/etc/env b/etc/env index 0314408..5fe38a7 100644 --- a/etc/env +++ b/etc/env @@ -1,5 +1,8 @@ -# DevDesk configuration. Read by systemd (EnvironmentFile=) and by the devdesk -# scripts. Plain KEY=value lines only: systemd does not support inline comments. +# DevDesk defaults, shipped by the package. Don't edit: put your changes in +# /etc/devdesk/env.local (same KEY=value format, read after this file), so +# package upgrades never prompt about this file. +# Read by systemd (EnvironmentFile=) and by the devdesk scripts. Plain KEY=value +# lines only: systemd does not support inline comments. # The ttyd credential lives in /etc/devdesk/secret.env (generated on install). # Port ttyd listens on (NPM proxies dev.lotusguild.org here). @@ -15,6 +18,11 @@ DEVDESK_DEFAULT_SESSION=main # Where sessions that are not a project folder start (e.g. ops sessions); empty = projects root. DEVDESK_DEFAULT_DIR= +# Gitea for the Repo/CI panel. Public repos need no token; for private ones put a +# read:repository token in /etc/devdesk/gitea-token (mode 600). +DEVDESK_GITEA_URL=https://code.lotusguild.org +DEVDESK_GITEA_ALIASES=10.10.10.63 + # Claude Code binary and extra arguments for every session. CLAUDE_BIN=/root/.local/bin/claude CLAUDE_ARGS= diff --git a/libexec/devdesk-claude-run b/libexec/devdesk-claude-run index 878fadb..adebab9 100755 --- a/libexec/devdesk-claude-run +++ b/libexec/devdesk-claude-run @@ -15,10 +15,7 @@ SETTINGS="$DEVDESK_SHARE/claude-settings.json" # Re-read /etc/devdesk/env before every Claude launch, so changes (Remote # Control, CLAUDE_ARGS, …) apply on the next restart of Claude in a session. reload_config() { - if [ -r "$DEVDESK_ENV" ]; then - # shellcheck source=/dev/null - . "$DEVDESK_ENV" - fi + dd_load_env CLAUDE_BIN=${CLAUDE_BIN:-claude} read -r -a EXTRA <<<"${CLAUDE_ARGS:-}" } diff --git a/libexec/devdesk-lib.sh b/libexec/devdesk-lib.sh index 440a30d..5d9b952 100644 --- a/libexec/devdesk-lib.sh +++ b/libexec/devdesk-lib.sh @@ -5,10 +5,19 @@ # 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 +# 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}" @@ -18,6 +27,9 @@ fi : "${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_REGISTRY="$DEVDESK_STATE_DIR/registry.json" DEVDESK_CONTEXT_DIR="$DEVDESK_STATE_DIR/context" diff --git a/tests/attach.bats b/tests/attach.bats index 3ff0717..c8fab22 100644 --- a/tests/attach.bats +++ b/tests/attach.bats @@ -211,6 +211,41 @@ claude_args() { [ ! -e "$DEVDESK_STATE_DIR/context/stranger.json" ] } +@test "repo reports git state and recognises Gitea remotes" { + export DEVDESK_GITEA_URL=http://127.0.0.1:9 # nothing listens: CI lookups fail fast + repo="$DEVDESK_PROJECTS_ROOT/alpha" + git -C "$repo" init -q -b main + git -C "$repo" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init + git -C "$repo" remote add origin gitea@10.10.10.63:LotusGuild/alpha.git + touch "$repo/new-file" + run devdesk attach --repo alpha + [ "$status" -eq 0 ] + [ "$(jq -r '.git' <<<"$output")" = true ] + [ "$(jq -r '.branch' <<<"$output")" = main ] + [ "$(jq -r '.dirty' <<<"$output")" = 1 ] + [ "$(jq -r '.repo' <<<"$output")" = LotusGuild/alpha ] + [ "$(jq -r '.ci' <<<"$output")" = null ] + git -C "$repo" remote set-url origin https://127.0.0.1:9/LotusGuild/alpha-https.git + run devdesk repo alpha + [ "$(jq -r '.repo' <<<"$output")" = LotusGuild/alpha-https ] + git -C "$repo" remote set-url origin https://github.com/someone/else.git + run devdesk repo alpha + [ "$(jq -r '.repo' <<<"$output")" = null ] + run devdesk repo main + [ "$(jq -r '.git' <<<"$output")" = false ] +} + +@test "env.local overrides the packaged env" { + mkdir -p "$TMP/etc" "$DEVDESK_PROJECTS_ROOT/alpha/ops" + printf 'DEVDESK_DEFAULT_DIR=%s\n' "$DEVDESK_PROJECTS_ROOT" >"$TMP/etc/env" + printf 'DEVDESK_DEFAULT_DIR=%s\n' "$DEVDESK_PROJECTS_ROOT/alpha/ops" >"$TMP/etc/env.local" + export DEVDESK_ENV="$TMP/etc/env" + start_server + devdesk new adhoc + run tmux -S "$DEVDESK_SOCKET" display -p -t =adhoc:claude '#{pane_current_path}' + [ "$output" = "$DEVDESK_PROJECTS_ROOT/alpha/ops" ] +} + @test "web actions run through attach and answer with JSON" { start_server run devdesk attach --new web1 alpha diff --git a/web/index.template.html b/web/index.template.html index 6480ecd..5aeb36b 100644 --- a/web/index.template.html +++ b/web/index.template.html @@ -89,8 +89,8 @@ -
-
Repo / CI v1.1
+
+
Repo / CI
Repo— @@ -99,7 +99,7 @@ CI · HEADno data Open PRs—
-

Gitea status for this session's repo arrives with devdesk-api.

+

diff --git a/web/src/connection.js b/web/src/connection.js index 1c99550..b0933ec 100644 --- a/web/src/connection.js +++ b/web/src/connection.js @@ -124,6 +124,11 @@ export async function fetchSessionList() { return parseListOutput(await runCommand([LIST_ARG])); } +// Git + Gitea CI status for a session's folder (`devdesk attach --repo NAME`). +export async function fetchRepo(session) { + return parseListOutput(await runCommand(['--repo', session])); +} + // `devdesk attach --new|--rename|--end …`: resolves on {"ok":true}, otherwise // rejects with devdesk's own error message. export async function sessionAction(action, ...args) { diff --git a/web/src/main.js b/web/src/main.js index e4b4ccb..11834aa 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -1,9 +1,10 @@ /* global __DEVDESK_VERSION__ */ import { createTerminal, loadFonts } from './term.js'; -import { TtydConnection, fetchToken, AuthExpiredError, UnauthorizedError } from './connection.js'; +import { TtydConnection, fetchRepo, fetchToken, AuthExpiredError, UnauthorizedError } from './connection.js'; import { parseTitle, sessionFromSearch } from './protocol.js'; import { SessionsPanel, rememberSession } from './sessions.js'; import { renderContext } from './context.js'; +import { renderRepo } from './repo.js'; import { bindAskClaude } from './widgets.js'; import * as notify from './notify.js'; @@ -96,6 +97,34 @@ function showContext() { renderContext($('widget-context'), state.session, current?.context ?? null); } +// Repo / CI panel for the current session; notifies when HEAD's CI finishes. +let lastCi = { key: '', state: '' }; +let repoBusy = false; +async function refreshRepo() { + const session = state.session; + if (!session) { + renderRepo($('widget-repo'), null); + return; + } + if (repoBusy) return; + repoBusy = true; + try { + const repo = await fetchRepo(session); + if (session !== state.session) return; + renderRepo($('widget-repo'), repo); + const key = `${session}@${repo.sha}`; + const ci = repo.ci?.total ? repo.ci.state : ''; + if (key === lastCi.key && lastCi.state === 'pending' && ['success', 'failure', 'error'].includes(ci)) { + notify.ring(session, `CI ${ci === 'success' ? 'passed' : 'failed'} on ${repo.branch} @ ${repo.sha}`); + } + lastCi = { key, state: ci }; + } catch { + // Keep the last rendering; the next refresh retries. + } finally { + repoBusy = false; + } +} + let refreshTimer = 0; function refreshSoon(delay = 1500) { clearTimeout(refreshTimer); @@ -133,6 +162,7 @@ function connect(session) { setWsState('connected', 'live'); setSso('ok'); if (session) rememberSession(session); + refreshRepo(); fit.fit(); term.focus(); }, @@ -394,7 +424,10 @@ async function boot() { ui.sessionChip.textContent = to; sessionsPanel.setCurrent(to); }, - onData: () => showContext(), + onData: () => { + showContext(); + refreshRepo(); + }, }, }); bindAskClaude($('sidebar'), (text) => { diff --git a/web/src/repo.js b/web/src/repo.js new file mode 100644 index 0000000..d2baf13 --- /dev/null +++ b/web/src/repo.js @@ -0,0 +1,56 @@ +// Repo / CI panel: git state of the current session's folder plus the Gitea CI +// status of its HEAD commit (`devdesk attach --repo `). + +const CI = { + success: ['passing', 'lt-status-completed'], + failure: ['failed', 'lt-status-failed'], + error: ['error', 'lt-status-failed'], + pending: ['running', 'lt-status-running'], + warning: ['warning', 'lt-status-pending'], +}; + +function link(href, text) { + const a = document.createElement('a'); + a.href = href; + a.target = '_blank'; + a.rel = 'noopener'; + a.textContent = text; + return a; +} + +export function renderRepo(root, repo) { + const field = (name) => root.querySelector(`[data-field="${name}"]`); + const set = (name, value) => field(name).replaceChildren(value); + const note = root.querySelector('.dd-repo-note'); + + if (!repo || !repo.git) { + root.dataset.state = 'empty'; + for (const f of ['repo', 'branch', 'dirty', 'prs']) set(f, '—'); + const ci = field('ci'); + ci.className = 'lt-status lt-status-pending'; + ci.textContent = 'no data'; + note.textContent = repo + ? `${repo.dir} is not a git repository.` + : 'Open a session to see its repository.'; + return; + } + + root.dataset.state = 'live'; + set('repo', repo.url ? link(repo.url, repo.repo) : (repo.remote || 'no remote')); + const sync = repo.upstream + ? [repo.ahead ? `↑${repo.ahead}` : '', repo.behind ? `↓${repo.behind}` : ''].filter(Boolean).join(' ') || 'in sync' + : 'no upstream'; + set('branch', `${repo.branch} @ ${repo.sha || '—'} · ${sync}`); + set('dirty', repo.dirty ? `${repo.dirty} changed` : 'clean'); + + const ci = field('ci'); + const [label, cls] = repo.ci?.total ? (CI[repo.ci.state] || [repo.ci.state, 'lt-status-pending']) + : [repo.ahead ? 'not pushed' : 'no CI', 'lt-status-pending']; + ci.className = `lt-status ${cls}`; + ci.replaceChildren(repo.ci?.url ? link(repo.ci.url, label) : label); + + set('prs', repo.url && repo.prs != null + ? link(`${repo.url}/pulls`, `${repo.prs} open`) + : '—'); + note.textContent = repo.repo ? '' : 'Not a Gitea repository; CI status unavailable.'; +} diff --git a/web/styles.css b/web/styles.css index 0327d59..72a36c2 100644 --- a/web/styles.css +++ b/web/styles.css @@ -288,7 +288,11 @@ body.dd-sidebar-open .dd-sidebar { .dd-context-grid { margin-top: var(--space-sm); } [data-state="stub"] .lt-section-body, -#widget-context[data-state="empty"] .lt-kv-grid { opacity: 0.6; } +#widget-context[data-state="empty"] .lt-kv-grid, +#widget-repo[data-state="empty"] .lt-kv-grid { opacity: 0.6; } + +.lt-status a { color: inherit; text-shadow: none; } +.dd-widget .lt-kv-val { overflow-wrap: anywhere; } /* ---- overlay ----------------------------------------------------------------- */ .dd-overlay {