#!/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 < "$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