bvn13 be316cb781 fix: make dev-host.sh run on macOS and Git Bash, not just Linux
The host never started on Windows: `setsid: command not found`, after
which the script wrote the dead setsid pid to .dev-host.pid and counted
out its full timeout printing dots. setsid is Linux-only, so this would
have hit macOS too.

Four Linux assumptions, replaced with probed alternatives:

  setsid           used when present; otherwise plain nohup, and stop
                   walks the process tree instead of killing the group
  ss               falls back to lsof (macOS), then netstat (Windows)
  node -p require  reads the version with sed instead: bash hands Node
                   /c/Users/..., which Node resolves as C:\c\Users\...
                   and throws MODULE_NOT_FOUND
  --show-current   falls back to `describe`, empty on the detached HEAD
                   a fresh submodule checkout leaves behind

Also, so the next failure is legible rather than a wall of dots:
the readiness loop now stops the moment the host process dies, and the
timeout prints the log's error lines instead of `tail -5`, which the
per-file session-sync chatter had made useless. Timeout raised to 120s
for cold first starts (tsx compile + vite optimizeDeps), overridable
with READY_TIMEOUT_SECONDS.

Unrelated bug found while there: stop_host used `exit 0` when nothing was
running, so `restart` on a stopped host never reached start_host.

Verified on Linux: the three port backends agree, and the no-setsid path
was exercised in isolation — group kill fails as expected and kill_tree
reaps parent and children. The netstat and ps branches are written to
documented behaviour but untested for lack of a Windows or macOS host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:31:41 +03:00

261 lines
8.6 KiB
Bash
Executable File

#!/usr/bin/env bash
# Runs the patched host (cloudcli/, branch dev/taskwork) against your real
# environment — the same ~/.claude projects, the same ~/.cloudcli/auth.db, the
# same ~/.claude-code-ui/plugins as an installed CloudCLI. Nothing is sandboxed,
# so what you see is what a user would see.
#
# ./scripts/dev-host.sh start install the built plugin and start the host
# ./scripts/dev-host.sh stop stop it
# ./scripts/dev-host.sh status ports, pid, plugin version
# ./scripts/dev-host.sh logs follow the log
#
# Ports default to 3010/5183, not 3001/5173, so an already installed CloudCLI
# keeps running untouched. Override with SERVER_PORT / VITE_PORT.
#
# Runs on Linux, macOS and Git Bash on Windows. The platform differences are
# real and each is handled where it bites: setsid exists only on Linux, ss only
# on Linux, lsof not on Windows, and Node under Git Bash cannot open the
# /c/Users/... paths that bash hands it.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HOST_DIR="$ROOT/cloudcli"
LOG_FILE="$ROOT/.dev-host.log"
PID_FILE="$ROOT/.dev-host.pid"
SERVER_PORT="${SERVER_PORT:-3010}"
VITE_PORT="${VITE_PORT:-5183}"
READY_TIMEOUT_SECONDS="${READY_TIMEOUT_SECONDS:-120}"
have() { command -v "$1" >/dev/null 2>&1; }
running_pid() {
[ -f "$PID_FILE" ] || return 1
local pid
pid="$(cat "$PID_FILE")"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && echo "$pid"
}
# Is anything listening on this TCP port? Each platform ships a different tool:
# ss on Linux, lsof on macOS, netstat on Windows. When none is available the
# answer is "no", so an unknown environment never blocks the start.
port_in_use() {
local port="$1"
if have ss; then
ss -ltn 2>/dev/null | grep -q ":$port "
elif have lsof; then
lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1
elif have netstat; then
# `LISTEN` matches Windows' `LISTENING` too; macOS writes `*.3010`,
# Linux and Windows `0.0.0.0:3010`, hence the [.:] class.
netstat -an 2>/dev/null | grep -qE "[.:]${port}[[:space:]].*LISTEN"
else
return 1
fi
}
require_environment() {
# -e, not -d: in a submodule checkout .git is a *file* pointing at
# ../.git/modules/cloudcli. It is only a directory when the fork was cloned
# into place by hand.
[ -e "$HOST_DIR/.git" ] || { echo "cloudcli/ is not initialised — run ./scripts/bootstrap.sh" >&2; exit 1; }
[ -d "$HOST_DIR/node_modules" ] || { echo "cloudcli/node_modules is missing — run ./scripts/bootstrap.sh" >&2; exit 1; }
# The server needs these three compiled; npm >= 12 blocks install scripts by
# default, which leaves them unbuilt and the server dead on arrival.
local missing=()
for module in better-sqlite3 bcrypt node-pty; do
(cd "$HOST_DIR" && node -e "require('$module')") >/dev/null 2>&1 || missing+=("$module")
done
if [ ${#missing[@]} -gt 0 ]; then
echo "native modules not built: ${missing[*]}" >&2
echo "run ./scripts/bootstrap.sh, which builds them" >&2
exit 1
fi
}
warn_about_other_instances() {
port_in_use 3001 || return 0
cat >&2 <<EOF
note: another CloudCLI is listening on :3001.
This instance shares its database (~/.cloudcli/auth.db) and its plugin
directory. That is intentional here — set DATABASE_PATH to run against a
separate database instead.
EOF
}
# Starts `npm run dev` detached and writes its pid. setsid puts it in its own
# session, which is what lets stop_host kill the whole tree by process group.
# macOS and Git Bash have no setsid; there nohup alone still detaches the
# process, but it stays in this script's process group, so stop_host falls back
# to walking the children by hand.
start_detached() {
if have setsid; then
setsid nohup npm run dev > "$LOG_FILE" 2>&1 < /dev/null &
else
nohup npm run dev > "$LOG_FILE" 2>&1 < /dev/null &
fi
echo $! > "$PID_FILE"
}
start_host() {
local pid
if pid="$(running_pid)"; then
echo "already running (pid $pid) — http://localhost:$VITE_PORT"
exit 0
fi
require_environment
warn_about_other_instances
for port in "$SERVER_PORT" "$VITE_PORT"; do
if port_in_use "$port"; then
echo "port $port is already in use — pick another with SERVER_PORT/VITE_PORT" >&2
exit 1
fi
done
if [ -d "$ROOT/plugin-taskwork/build" ]; then
# Captured rather than piped into `head -1`: head closes the pipe after the
# first line, link-plugin.sh dies of SIGPIPE on its next write, and pipefail
# turns that into a silent exit here.
local plugin_output
plugin_output="$("$ROOT/scripts/link-plugin.sh")"
printf '%s\n' "${plugin_output%%$'\n'*}"
else
echo "note: plugin-taskwork/build is missing, the host will start without the plugin"
fi
( cd "$HOST_DIR" && SERVER_PORT="$SERVER_PORT" VITE_PORT="$VITE_PORT" start_detached )
printf 'starting'
for _ in $(seq "$READY_TIMEOUT_SECONDS"); do
if curl -sf "http://localhost:$SERVER_PORT/api/auth/status" >/dev/null 2>&1; then
echo
echo "host http://localhost:$VITE_PORT (api :$SERVER_PORT, pid $(cat "$PID_FILE"))"
grep -m1 '\[Plugins\] Server started' "$LOG_FILE" 2>/dev/null || echo "note: no plugin server in the log yet"
echo "log $LOG_FILE"
exit 0
fi
# The host died rather than started: say so now instead of counting to 120.
running_pid >/dev/null || break
printf '.'
sleep 1
done
echo
echo "the host is not answering on :$SERVER_PORT — see $LOG_FILE" >&2
report_log_errors
exit 1
}
# The log is dominated by per-file session-sync chatter, so a plain tail almost
# never shows the reason a start failed. Pull the lines that carry one.
report_log_errors() {
[ -f "$LOG_FILE" ] || return 0
local errors
errors="$(grep -iE 'error|EADDRINUSE|EACCES|cannot find module|MODULE_NOT_FOUND|not recognized|command not found|failed' "$LOG_FILE" 2>/dev/null | tail -10 || true)"
if [ -n "$errors" ]; then
echo "--- errors in the log ---" >&2
printf '%s\n' "$errors" >&2
else
echo "--- last lines of the log ---" >&2
tail -10 "$LOG_FILE" >&2 || true
fi
}
# Children of a pid, for the platforms where killing the process group is not an
# option. pgrep covers Linux and macOS; Git Bash has no pgrep, but its ps prints
# PID and PPID as the first two columns.
child_pids() {
if have pgrep; then
pgrep -P "$1" 2>/dev/null || true
else
ps 2>/dev/null | awk -v parent="$1" 'NR > 1 && $2 == parent { print $1 }' || true
fi
}
kill_tree() {
local pid="$1" child
# Depth first: children die before the parent that would otherwise respawn
# or orphan them.
for child in $(child_pids "$pid"); do
[ "$child" = "$pid" ] || kill_tree "$child"
done
kill "$pid" 2>/dev/null || true
}
stop_host() {
local pid
# `return`, not `exit`: `restart` on a stopped host must still go on to start it.
if ! pid="$(running_pid)"; then
echo "not running"
rm -f "$PID_FILE"
return 0
fi
# npm run dev spawns concurrently, which spawns the server and vite. Killing
# the process group takes all of them at once where setsid made one; where it
# did not, walk the tree instead.
kill -- "-$pid" 2>/dev/null || kill_tree "$pid"
for _ in $(seq 10); do
kill -0 "$pid" 2>/dev/null || break
sleep 0.5
done
kill -9 -- "-$pid" 2>/dev/null || kill -9 "$pid" 2>/dev/null || true
rm -f "$PID_FILE"
echo "stopped"
}
# Reads one string field out of a JSON file. Deliberately not `node -p
# require(...)`: under Git Bash $HOME is /c/Users/..., which bash resolves and
# Node does not — it reads that as C:\c\Users\... and throws MODULE_NOT_FOUND.
json_string_field() {
local file="$1" field="$2"
sed -n "s/.*\"$field\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$file" 2>/dev/null | head -1
}
host_ref() {
local branch
branch="$(git -C "$HOST_DIR" branch --show-current 2>/dev/null || true)"
# Empty means detached HEAD, which is the normal state of a fresh submodule
# checkout — describe the commit instead of printing nothing.
[ -n "$branch" ] || branch="$(git -C "$HOST_DIR" describe --all --always 2>/dev/null || echo '?')"
echo "$branch"
}
show_status() {
local pid
if pid="$(running_pid)"; then
echo "running pid $pid, http://localhost:$VITE_PORT (api :$SERVER_PORT)"
else
echo "stopped"
fi
echo "branch $(host_ref)"
local installed="$HOME/.claude-code-ui/plugins/cloudcli-plugin-taskwork/manifest.json"
if [ -f "$installed" ]; then
local version
version="$(json_string_field "$installed" version)"
echo "plugin ${version:-unknown version} installed in ~/.claude-code-ui/plugins"
else
echo "plugin not installed — ./scripts/link-plugin.sh"
fi
}
case "${1:-start}" in
start) start_host ;;
stop) stop_host ;;
restart) stop_host; start_host ;;
status) show_status ;;
logs) tail -f "$LOG_FILE" ;;
*) echo "usage: $0 [start|stop|restart|status|logs]" >&2; exit 1 ;;
esac