From be316cb78109e425b528536790cdf66c3bc58d5c Mon Sep 17 00:00:00 2001 From: bvn13 Date: Sat, 22 Aug 2026 12:31:41 +0300 Subject: [PATCH] fix: make dev-host.sh run on macOS and Git Bash, not just Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/dev-host.sh | 137 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 113 insertions(+), 24 deletions(-) diff --git a/scripts/dev-host.sh b/scripts/dev-host.sh index a6916ff..d5c205a 100755 --- a/scripts/dev-host.sh +++ b/scripts/dev-host.sh @@ -11,6 +11,11 @@ # # 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)" @@ -20,7 +25,9 @@ PID_FILE="$ROOT/.dev-host.pid" SERVER_PORT="${SERVER_PORT:-3010}" VITE_PORT="${VITE_PORT:-5183}" -READY_TIMEOUT_SECONDS=60 +READY_TIMEOUT_SECONDS="${READY_TIMEOUT_SECONDS:-120}" + +have() { command -v "$1" >/dev/null 2>&1; } running_pid() { [ -f "$PID_FILE" ] || return 1 @@ -29,8 +36,22 @@ running_pid() { [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && echo "$pid" } -port_owner() { - ss -ltnp 2>/dev/null | awk -v port=":$1" '$4 ~ port' | grep -oE 'pid=[0-9]+' | head -1 +# 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() { @@ -55,18 +76,30 @@ require_environment() { } warn_about_other_instances() { - local other - other="$(port_owner 3001 || true)" - [ -n "$other" ] || return 0 + 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 @@ -78,7 +111,7 @@ start_host() { warn_about_other_instances for port in "$SERVER_PORT" "$VITE_PORT"; do - if ss -ltn 2>/dev/null | grep -q ":$port "; then + if port_in_use "$port"; then echo "port $port is already in use — pick another with SERVER_PORT/VITE_PORT" >&2 exit 1 fi @@ -95,14 +128,7 @@ start_host() { echo "note: plugin-taskwork/build is missing, the host will start without the plugin" fi - # setsid detaches from this shell's process group, so the host survives the - # terminal (or the agent turn) that started it. - ( - cd "$HOST_DIR" - SERVER_PORT="$SERVER_PORT" VITE_PORT="$VITE_PORT" \ - setsid nohup npm run dev > "$LOG_FILE" 2>&1 < /dev/null & - echo $! > "$PID_FILE" - ) + ( cd "$HOST_DIR" && SERVER_PORT="$SERVER_PORT" VITE_PORT="$VITE_PORT" start_detached ) printf 'starting' for _ in $(seq "$READY_TIMEOUT_SECONDS"); do @@ -113,36 +139,97 @@ start_host() { 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 did not answer within ${READY_TIMEOUT_SECONDS}s — see $LOG_FILE" >&2 - tail -5 "$LOG_FILE" >&2 || true + 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" - exit 0 + return 0 fi - # npm run dev spawns the server and vite; killing the group takes all of them. - kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true + # 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 || true + 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 @@ -151,11 +238,13 @@ show_status() { echo "stopped" fi - echo "branch $(git -C "$HOST_DIR" branch --show-current 2>/dev/null || echo '?')" + echo "branch $(host_ref)" local installed="$HOME/.claude-code-ui/plugins/cloudcli-plugin-taskwork/manifest.json" if [ -f "$installed" ]; then - echo "plugin $(node -p "require('$installed').version") installed in ~/.claude-code-ui/plugins" + 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