CB-624: add scripts/rename-checkout.sh (rename checkout claude-bridge -> fleetd) #143

Merged
ltms merged 2 commits from worker/cb-624-script-ff7bc2-3 into main 2026-08-23 06:09:19 +02:00
+484
View File
@@ -0,0 +1,484 @@
#!/usr/bin/env bash
#
# Rename this checkout from ~/LTMS/claude-bridge to ~/LTMS/fleetd.
#
# The project was renamed upstream (fleet/fleetd on git.ltms.dev), but the checkout keeps its old
# name. Renaming it by hand misses surfaces, because the absolute path is written down in more
# places than the directory itself:
#
# 1. The launchd agent (~/Library/LaunchAgents/dev.ltms.bridged.plist) hard-codes absolute paths
# to the wrapper script and the jar. Move the checkout without fixing it and the daemon never
# starts again after a reboot.
# 2. Every git worktree carries a `.git` FILE with an ABSOLUTE pointer into the main checkout
# (`gitdir: /…/claude-bridge/.git/worktrees/<id>`). Rename the main checkout and every
# worktree breaks at once ("not a git repository").
# 3. Claude Code keys saved sessions and memory by a slug derived from the working directory,
# with `/` and `.` replaced by `-`. Move the checkout and it computes a NEW slug, finds
# nothing, and silently starts empty — every saved session is still on disk, just unreachable.
# 4. bridged/bridged.yaml is gitignored, so it survives only in the live checkout and holds
# `fleet.leaders.*.cwd` and `configDir` — both absolute, both old.
# 5. A running daemon holds the old paths open. Moving underneath it corrupts its state, so the
# daemon stops FIRST, and the script waits for the process to actually exit rather than
# assuming (same trap as redeploy-bridged.sh).
# 6. An old daemon that never died looks identical from the outside, so verification polls
# /healthz AND demands a FRESH "bridged listening" log line dated after the restart, anchored
# to a line-count marker taken before anything moved.
#
# Three more surfaces hold the old path but this script REPORTS them and never rewrites them.
# That is a decision, not an oversight:
#
# A. ~/.claude.json — Claude Code's GLOBAL config for every project on this machine (the
# `projects` map keys projects by their absolute path). It is written by live sessions while
# this script could run, and one bad edit there breaks every project on the machine, not just
# this repo. The entry holds trust (`hasTrustDialogAccepted`), onboarding state,
# `enabledMcpjsonServers`, `allowedTools` and `lastSessionId`.
# B. ~/.config/herdr/session.json — live session records of a RUNNING process. Rewriting it
# underneath herdr corrupts its state.
# C. JetBrains options (recentProjects.xml, trusted-paths.xml under any IntellIJIDEA* version) —
# the IDE rewrites these itself when the project is reopened at the new path, so editing them
# by hand is both unnecessary and racy with a possibly-running IDE.
#
# For each of the three, --check prints the exact manual follow-up instead of fixing it.
#
# Usage:
# scripts/rename-checkout.sh --check # report what holds the old path; changes nothing
# scripts/rename-checkout.sh # stop daemon, move everything, restart, verify
# scripts/rename-checkout.sh --yes # skip the confirmation prompt
#
# Run this FROM INSIDE OR OUTSIDE the checkout — it operates on fixed absolute paths, never on
# $PWD, because after the rename $PWD itself is gone.
#
# Exits non-zero on any failure. A failed pre-flight never touches the daemon.
set -euo pipefail
OLD_PATH="/Users/dai.ha/LTMS/claude-bridge"
NEW_PATH="/Users/dai.ha/LTMS/fleetd"
BRIDGED_SUB="bridged" # daemon lives at <checkout>/bridged/
JAR_NAME="bridged.jar"
OUT_NAME="bridged.out"
CC_INSTANCE="ltms"
CC_PROJECTS="$HOME/.ccs/instances/$CC_INSTANCE/projects"
# Report-only surfaces (see header, items A-C) — never rewritten by apply mode.
CLAUDE_JSON="$HOME/.claude.json"
HERDR_SESSION="$HOME/.config/herdr/session.json"
JETBRAINS_ROOT="$HOME/Library/Application Support/JetBrains"
JETBRAINS_FILES=(recentProjects.xml trusted-paths.xml)
LAUNCHD_LABEL='dev.ltms.bridged'
LAUNCHD_PLIST="$HOME/Library/LaunchAgents/$LAUNCHD_LABEL.plist"
HEALTH='http://127.0.0.1:8765/healthz'
PATTERN="target/$JAR_NAME"
STOP_WAIT=30 # seconds to wait for a clean daemon exit before reporting failure
HEALTH_WAIT=60 # seconds to wait for /healthz to answer after start
ASSUME_YES=0; CHECK_ONLY=0
for arg in "$@"; do
case "$arg" in
--check) CHECK_ONLY=1 ;;
--yes|-y) ASSUME_YES=1 ;;
-h|--help) sed -n '3,40p' "${BASH_SOURCE[0]}"; exit 0 ;;
*) echo "unknown option: $arg (try --help)" >&2; exit 2 ;;
esac
done
say() { printf '\n\033[1m== %s\033[0m\n' "$*"; }
ok() { printf ' ok %s\n' "$*"; }
warn() { printf ' WARN %s\n' "$*"; }
die() { printf '\n FAIL %s\n\n' "$*" >&2; exit 1; }
running_pid() { pgrep -f "$PATTERN" || true; }
launchd_installed() { [ -f "$LAUNCHD_PLIST" ]; }
launchd_loaded() { launchctl list "$LAUNCHD_LABEL" >/dev/null 2>&1; }
# Claude Code derives its per-project directory name from the working directory by replacing
# "/" and "." with "-". Derived here, never hardcoded, so the script stays correct if either
# endpoint path ever changes again.
cc_slug() { printf '%s' "$1" | tr '/.' '-'; }
OLD_OUT="$OLD_PATH/$BRIDGED_SUB/$OUT_NAME"
NEW_OUT="$NEW_PATH/$BRIDGED_SUB/$OUT_NAME"
# Files under a directory that contain the literal old path, with a hit count each. Read-only.
# Skips .git internals, build output and node_modules: they are generated, huge, and repaired by
# rebuilding rather than rewriting.
scan_old_path_in() {
local root="$1"
[ -e "$root" ] || return 0
grep -rIlF --exclude-dir=.git --exclude-dir=target --exclude-dir=node_modules \
"$OLD_PATH" "$root" 2>/dev/null || true
}
report_hits() {
local label="$1" root="$2" f n total=0
while IFS= read -r f; do
n="$(grep -cF "$OLD_PATH" "$f" 2>/dev/null || true)"
printf ' %5d %s\n' "$n" "${f#"$root"/}"
total=$((total + 1))
done < <(scan_old_path_in "$root")
if [ "$total" -eq 0 ]; then
ok "$label: no files reference the old path"
else
warn "$label: $total file(s) reference the old path:"
fi
}
# ---------------------------------------------------------------------------
# Report-only surfaces (header items A-C). Counted here, NEVER rewritten by
# apply mode — the header comment explains why each is left alone. Both
# --check (section 6) and apply mode's closing summary call this, so the two
# reports can never drift apart.
# ---------------------------------------------------------------------------
count_old_path_in_file() { # <file> <fixed-string-pattern> -> stdout: hit count (0 if file absent)
if [ ! -f "$1" ]; then echo 0; return; fi
# "no match" makes grep exit 1 and, under `set -o pipefail`, the whole pipeline too — so the
# assignment needs its own `|| true`; wc still prints the correct 0.
local n
n="$(grep -oF -- "$2" "$1" 2>/dev/null | wc -l | tr -d ' ')" || true
printf '%s' "${n:-0}"
}
report_only_surfaces() {
local n
# A. Claude Code global config — the per-project entry keyed by absolute path.
if [ -f "$CLAUDE_JSON" ]; then
n="$(count_old_path_in_file "$CLAUDE_JSON" "$OLD_PATH")"
KEY="unknown"
if command -v python3 >/dev/null 2>&1; then
KEY="$(python3 -c 'import json,sys
d = json.load(open(sys.argv[1]))
print("present" if sys.argv[2] in d.get("projects", {}) else "absent")' \
"$CLAUDE_JSON" "$OLD_PATH" 2>/dev/null)" || KEY="unknown"
fi
warn "A. $CLAUDE_JSON — old-path occurrences: $n; projects[\"$OLD_PATH\"] entry: $KEY"
echo " holds trust/onboarding state, enabledMcpjsonServers, allowedTools, lastSessionId."
echo " FOLLOW-UP: quit every Claude Code session first (this file is rewritten on exit),"
echo " then rename the key projects[\"$OLD_PATH\"] -> projects[\"$NEW_PATH\"], keeping the"
echo " entry's contents. If skipped: the trust dialog appears once at the new path and"
echo " .mcp.json servers start disabled until re-enabled."
else
ok "A. $CLAUDE_JSON absent — nothing to report"
fi
# B. herdr live session records.
n="$(count_old_path_in_file "$HERDR_SESSION" "$OLD_PATH")"
if [ "$n" -gt 0 ]; then
warn "B. $HERDR_SESSION — $n occurrence(s) of the old path (live herdr state)"
echo " FOLLOW-UP: stop the daemon/herdr FIRST, then edit the file (or leave it: sessions"
echo " started from the new checkout afterwards use the new path). Never edit while herdr runs."
elif [ -f "$HERDR_SESSION" ]; then
ok "B. $HERDR_SESSION does not reference the old path"
else
ok "B. $HERDR_SESSION absent"
fi
# C. JetBrains recent projects + trusted paths, any IntelliJIdea version.
# JetBrains stores these paths as its own "$USER_HOME$/..." MACRO, not as the literal absolute
# path, so a plain absolute-path grep reports 0 hits on files that are in fact full of them.
# Count both forms; report the macro form when that is where the hits live.
FOUND=0
JET_MACRO='$USER_HOME$'"${OLD_PATH#"$HOME"}" # e.g. $USER_HOME$/LTMS/claude-bridge
if [ -d "$JETBRAINS_ROOT" ]; then
for VDIR in "$JETBRAINS_ROOT"/IntelliJIdea*/; do
[ -d "$VDIR" ] || continue
VDIR="${VDIR%/}"
for F in "${JETBRAINS_FILES[@]}"; do
FPATH="$VDIR/options/$F"
ABS_N="$(count_old_path_in_file "$FPATH" "$OLD_PATH")"
MACRO_N="$(count_old_path_in_file "$FPATH" "$JET_MACRO")"
n=$((ABS_N + MACRO_N))
if [ "$n" -gt 0 ]; then
warn "C. ${FPATH#"$HOME"/} — $n occurrence(s) of the old path ($MACRO_N as the \${USER_HOME} macro, $ABS_N literal)"
FOUND=$((FOUND + 1))
fi
done
done
fi
if [ "$FOUND" -eq 0 ]; then
ok "C. no JetBrains IntelliJIdea* options file references the old path"
else
echo " FOLLOW-UP: open the project at $NEW_PATH in IntelliJ — the IDE rewrites"
echo " recentProjects.xml and trusted-paths.xml itself (it asks to trust once). Editing"
echo " these by hand is unnecessary and racy with a running IDE."
fi
}
# ---------------------------------------------------------------------- --check
if [ "$CHECK_ONLY" = 1 ]; then
say "--check (read-only): who holds $OLD_PATH"
say "1. files containing the old absolute path"
if [ -d "$OLD_PATH" ]; then
report_hits "checkout ($OLD_PATH)" "$OLD_PATH"
else
warn "checkout $OLD_PATH does not exist — skipping its scan"
fi
if launchd_installed; then
report_hits "installed launchd plist ($LAUNCHD_PLIST)" "$LAUNCHD_PLIST"
else
warn "installed launchd plist not found at $LAUNCHD_PLIST"
fi
report_hits "Claude Code instance data ($CC_PROJECTS)" "$CC_PROJECTS"
say "2. launchd agent"
if launchd_installed; then
ok "installed: $LAUNCHD_PLIST"
ARGS="$(/usr/libexec/PlistBuddy -c 'Print :ProgramArguments' "$LAUNCHD_PLIST" 2>/dev/null | grep -F '/' || true)"
if printf '%s' "$ARGS" | grep -qF "$OLD_PATH"; then
warn "plist ProgramArguments point at the OLD path:"
printf '%s\n' "$ARGS" | sed 's/^/ /'
elif [ -n "$ARGS" ]; then
ok "plist ProgramArguments do not reference the old path"
else
warn "could not read ProgramArguments from the plist"
fi
else
warn "launchd agent NOT installed (daemon would be started by hand only)"
fi
if launchd_loaded; then
ok "loaded: launchd supervises '$LAUNCHD_LABEL'"
else
warn "launchd agent not loaded"
fi
say "3. Claude Code project-state directory"
OLD_SLUG="$(cc_slug "$OLD_PATH")"; NEW_SLUG="$(cc_slug "$NEW_PATH")"
if [ -d "$CC_PROJECTS/$OLD_SLUG" ]; then
ok "old slug EXISTS: $CC_PROJECTS/$OLD_SLUG ($(find "$CC_PROJECTS/$OLD_SLUG" -type f | wc -l | tr -d ' ') files)"
else
warn "old slug missing: $CC_PROJECTS/$OLD_SLUG"
fi
if [ -e "$CC_PROJECTS/$NEW_SLUG" ]; then
warn "new slug ALREADY EXISTS: $CC_PROJECTS/$NEW_SLUG — moving over it needs a decision"
else
ok "new slug absent (clean): $CC_PROJECTS/$NEW_SLUG"
fi
say "4. git worktrees"
if [ -d "$OLD_PATH" ]; then
git -C "$OLD_PATH" worktree list || warn "'git worktree list' failed"
BROKEN=0
while IFS= read -r wt; do
[ "$wt" = "$OLD_PATH" ] && continue # the main checkout moves WITH the repo
GITFILE="$wt/.git"
if [ -f "$GITFILE" ] && grep -qF "$OLD_PATH" "$GITFILE" 2>/dev/null; then
warn "worktree $wt — .git pointer references the old path (breaks on rename)"
BROKEN=$((BROKEN + 1))
fi
done < <(git -C "$OLD_PATH" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')
if [ "$BROKEN" -eq 0 ]; then
ok "no external worktree .git pointer references the old path"
fi
else
warn "checkout $OLD_PATH missing — cannot enumerate worktrees"
fi
say "5. daemon process"
PID="$(running_pid)"
if [ -n "$PID" ]; then
ok "daemon running, pid $PID"
CMD="$(ps -p "$PID" -o command= 2>/dev/null || true)"
if printf '%s' "$CMD" | grep -qF "$OLD_PATH"; then
warn "it runs the OLD path: $CMD"
else
ok "command: $CMD"
fi
else
warn "no daemon running"
fi
say "6. report-only surfaces (counted, NEVER rewritten — see header items A-C)"
report_only_surfaces
say "--check done: nothing changed"
exit 0
fi
# ------------------------------------------------------------------- apply mode
say "pre-flight"
[ -d "$OLD_PATH" ] || die "old checkout $OLD_PATH does not exist — nothing to rename"
[ -e "$NEW_PATH" ] && die "new path $NEW_PATH already exists — refusing to overwrite"
if [ -n "$(git -C "$OLD_PATH" status --porcelain 2>/dev/null)" ]; then
git -C "$OLD_PATH" status --short | sed 's/^/ /'
die "working tree at $OLD_PATH is dirty — commit or stash first, then rerun"
fi
ok "working tree clean, target path free"
if [ "$ASSUME_YES" = 0 ]; then
echo
echo " This MOVES the checkout this system runs from and RESTARTS the daemon."
echo " If you are connected through that daemon, your connection dies mid-turn."
echo
read -r -p " Type yes to continue: " reply
[ "$reply" = "yes" ] || die "aborted — nothing changed"
fi
SUPERVISED=0
launchd_loaded && SUPERVISED=1
# ------------------------------------------------------- stop the daemon FIRST
say "stop"
OLD_PID="$(running_pid)"
RESTART_MARK="$(wc -l < "$OLD_OUT" 2>/dev/null || echo 0)" # demand a FRESH listening line later
if [ -n "$OLD_PID" ]; then
if [ "$SUPERVISED" = 1 ]; then
echo " supervision is ON: 'launchctl unload' (not kill) so launchd's KeepAlive cannot"
echo " restart the OLD jar out from under this script."
launchctl unload -w "$LAUNCHD_PLIST" \
|| die "launchctl unload failed — investigate before retrying; NOTHING has been moved"
else
kill "$OLD_PID"
fi
for _ in $(seq "$STOP_WAIT"); do
[ -z "$(running_pid)" ] && break
sleep 1
done
[ -z "$(running_pid)" ] || die "pid $OLD_PID still alive after ${STOP_WAIT}s — NOT escalating to kill -9 and NOT moving anything. Wait or stop it by hand."
ok "pid $OLD_PID exited"
elif [ "$SUPERVISED" = 1 ]; then
launchctl unload -w "$LAUNCHD_PLIST" 2>/dev/null || true
ok "launchd agent unloaded (daemon was not running)"
else
ok "daemon was not running"
fi
# ------------------------------------------------------------------- move things
say "move"
mv "$OLD_PATH" "$NEW_PATH" || die "mv $OLD_PATH -> $NEW_PATH failed — the checkout is now at its old name, nothing else was touched"
ok "checkout moved: $OLD_PATH -> $NEW_PATH"
OLD_SLUG="$(cc_slug "$OLD_PATH")"; NEW_SLUG="$(cc_slug "$NEW_PATH")"
if [ -d "$CC_PROJECTS/$OLD_SLUG" ]; then
if [ -e "$CC_PROJECTS/$NEW_SLUG" ]; then
warn "NOT moving Claude Code state: $CC_PROJECTS/$NEW_SLUG already exists — merge by hand"
else
mv "$CC_PROJECTS/$OLD_SLUG" "$CC_PROJECTS/$NEW_SLUG" \
|| warn "moving Claude Code project state failed — sessions remain under the old slug"
ok "Claude Code project state moved: $OLD_SLUG -> $NEW_SLUG"
fi
else
warn "no Claude Code project state at $CC_PROJECTS/$OLD_SLUG — nothing to move"
fi
# ------------------------------------------------------- repair the worktrees
say "repair worktrees"
FIXED=0
for GITDIR_FILE in "$NEW_PATH"/.git/worktrees/*/gitdir; do
[ -f "$GITDIR_FILE" ] || continue
WT_GIT="$(cat "$GITDIR_FILE")"
if [ -f "$WT_GIT" ] && grep -qF "$OLD_PATH" "$WT_GIT"; then
sed -i '' "s|$OLD_PATH|$NEW_PATH|g" "$WT_GIT" \
|| warn "could not rewrite $WT_GIT — that worktree stays broken until fixed by hand"
FIXED=$((FIXED + 1))
fi
done
if git -C "$NEW_PATH" worktree list >/dev/null 2>&1; then
ok "main repo answers after the rename"
else
die "git cannot read $NEW_PATH after the rename — inspect .git by hand"
fi
if [ "$FIXED" -gt 0 ]; then
ok "rewrote $FIXED worktree .git pointer(s) to the new path"
else
ok "no worktree pointer referenced the old path"
fi
# ------------------------------------- rewrite gitignored config, if it survived
YAML="$NEW_PATH/$BRIDGED_SUB/bridged.yaml"
if [ -f "$YAML" ]; then
if grep -qF "$OLD_PATH" "$YAML"; then
cp "$YAML" "$YAML.pre-rename.bak"
sed -i '' "s|$OLD_PATH|$NEW_PATH|g" "$YAML"
ok "rewrote $YAML (backup: $YAML.pre-rename.bak)"
else
ok "$YAML does not reference the old path"
fi
else
warn "no $YAML present — skipped (this branch could not be exercised from a worktree)"
fi
# --------------------------------------------------------- fix + reload launchd
say "launchd"
if launchd_installed; then
if grep -qF "$OLD_PATH" "$LAUNCHD_PLIST"; then
cp "$LAUNCHD_PLIST" "$LAUNCHD_PLIST.pre-rename.bak"
sed -i '' "s|$OLD_PATH|$NEW_PATH|g" "$LAUNCHD_PLIST"
plutil -lint "$LAUNCHD_PLIST" >/dev/null \
|| die "rewritten plist fails plutil -lint — restore $LAUNCHD_PLIST.pre-rename.bak and fix by hand"
ok "rewrote $LAUNCHD_PLIST (backup beside it)"
else
ok "installed plist does not reference the old path"
fi
else
warn "launchd agent not installed — starting unsupervised below"
fi
# ------------------------------------------------------------- start + verify
say "start"
if [ "$SUPERVISED" = 1 ]; then
launchctl load -w "$LAUNCHD_PLIST" || die "launchctl load failed — recover with: launchctl load -w \"$LAUNCHD_PLIST\""
else
JAR="$NEW_PATH/$BRIDGED_SUB/target/$JAR_NAME"
[ -f "$JAR" ] || die "no jar at $JAR — build in the new location, then rerun"
( cd "$NEW_PATH/$BRIDGED_SUB" && zsh -lc "nohup java -jar '$JAR' >> $OUT_NAME 2>&1 &" )
fi
for _ in $(seq 10); do
NEW_PID="$(running_pid)"; [ -n "$NEW_PID" ] && break; sleep 1
done
[ -n "${NEW_PID:-}" ] || die "no daemon process appeared. Last lines of $NEW_OUT:
$(tail -20 "$NEW_OUT" 2>/dev/null)"
[ "$NEW_PID" != "${OLD_PID:-}" ] || die "pid unchanged ($NEW_PID) — the old daemon never died"
ok "started, pid $NEW_PID"
say "verify"
HEALTH_OK=0; CODE=""
for _ in $(seq "$HEALTH_WAIT"); do
CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 "$HEALTH" 2>/dev/null || echo 000)"
case "$CODE" in
200) HEALTH_OK=1; ok "/healthz 200"; break ;;
# 503 still means the daemon is UP — it means herdr is unreachable.
503) HEALTH_OK=1; warn "/healthz answers 503 degraded — daemon up, herdr unreachable"; break ;;
esac
sleep 1
done
[ "$HEALTH_OK" = 1 ] || die "/healthz never answered within ${HEALTH_WAIT}s (last code: ${CODE:-000})"
if tail -n "+$((RESTART_MARK + 1))" "$NEW_OUT" 2>/dev/null | grep -q 'bridged listening'; then
ok "$(tail -n "+$((RESTART_MARK + 1))" "$NEW_OUT" | grep 'bridged listening' | tail -1)"
else
warn "no fresh 'bridged listening' line after the restart — check $NEW_OUT yourself"
fi
# ------------------------------------------------------------------- summary
say "changed"
echo " checkout: $OLD_PATH -> $NEW_PATH"
echo " claude code state: $CC_PROJECTS/$OLD_SLUG -> $CC_PROJECTS/$NEW_SLUG (when present)"
echo " worktree .git ptr: rewritten where they named the old path"
[ -f "$YAML" ] && echo " bridged.yaml: rewritten (backup kept)"
launchd_installed && echo " launchd plist: rewritten (backup kept)"
say "still referencing the old path (left alone on purpose)"
LEFT=$(scan_old_path_in "$NEW_PATH")
if [ -n "$LEFT" ]; then
printf '%s\n' "$LEFT" | sed 's/^/ /'
echo " Review these by hand: tracked docs and IDE state are yours to update,"
echo " session history must not be rewritten wholesale."
else
ok "(none inside the checkout)"
fi
scan_old_path_in "$CC_PROJECTS" | head -5 | sed 's/^/ /' || true
echo " (session transcripts quote the old cwd — history, left untouched)"
say "left alone on purpose — report-only surfaces and their manual follow-up"
report_only_surfaces
echo
echo " Next: reconnect leads/members to the renamed checkout and re-check with --check."