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>
This commit is contained in:
parent
7c6299053e
commit
be316cb781
@ -11,6 +11,11 @@
|
|||||||
#
|
#
|
||||||
# Ports default to 3010/5183, not 3001/5173, so an already installed CloudCLI
|
# Ports default to 3010/5183, not 3001/5173, so an already installed CloudCLI
|
||||||
# keeps running untouched. Override with SERVER_PORT / VITE_PORT.
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
@ -20,7 +25,9 @@ PID_FILE="$ROOT/.dev-host.pid"
|
|||||||
|
|
||||||
SERVER_PORT="${SERVER_PORT:-3010}"
|
SERVER_PORT="${SERVER_PORT:-3010}"
|
||||||
VITE_PORT="${VITE_PORT:-5183}"
|
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() {
|
running_pid() {
|
||||||
[ -f "$PID_FILE" ] || return 1
|
[ -f "$PID_FILE" ] || return 1
|
||||||
@ -29,8 +36,22 @@ running_pid() {
|
|||||||
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && echo "$pid"
|
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && echo "$pid"
|
||||||
}
|
}
|
||||||
|
|
||||||
port_owner() {
|
# Is anything listening on this TCP port? Each platform ships a different tool:
|
||||||
ss -ltnp 2>/dev/null | awk -v port=":$1" '$4 ~ port' | grep -oE 'pid=[0-9]+' | head -1
|
# 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() {
|
require_environment() {
|
||||||
@ -55,18 +76,30 @@ require_environment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
warn_about_other_instances() {
|
warn_about_other_instances() {
|
||||||
local other
|
port_in_use 3001 || return 0
|
||||||
other="$(port_owner 3001 || true)"
|
|
||||||
[ -n "$other" ] || return 0
|
|
||||||
|
|
||||||
cat >&2 <<EOF
|
cat >&2 <<EOF
|
||||||
note: another CloudCLI is listening on :3001 (${other}).
|
note: another CloudCLI is listening on :3001.
|
||||||
This instance shares its database (~/.cloudcli/auth.db) and its plugin
|
This instance shares its database (~/.cloudcli/auth.db) and its plugin
|
||||||
directory. That is intentional here — set DATABASE_PATH to run against a
|
directory. That is intentional here — set DATABASE_PATH to run against a
|
||||||
separate database instead.
|
separate database instead.
|
||||||
EOF
|
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() {
|
start_host() {
|
||||||
local pid
|
local pid
|
||||||
if pid="$(running_pid)"; then
|
if pid="$(running_pid)"; then
|
||||||
@ -78,7 +111,7 @@ start_host() {
|
|||||||
warn_about_other_instances
|
warn_about_other_instances
|
||||||
|
|
||||||
for port in "$SERVER_PORT" "$VITE_PORT"; do
|
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
|
echo "port $port is already in use — pick another with SERVER_PORT/VITE_PORT" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@ -95,14 +128,7 @@ start_host() {
|
|||||||
echo "note: plugin-taskwork/build is missing, the host will start without the plugin"
|
echo "note: plugin-taskwork/build is missing, the host will start without the plugin"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# setsid detaches from this shell's process group, so the host survives the
|
( cd "$HOST_DIR" && SERVER_PORT="$SERVER_PORT" VITE_PORT="$VITE_PORT" start_detached )
|
||||||
# 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"
|
|
||||||
)
|
|
||||||
|
|
||||||
printf 'starting'
|
printf 'starting'
|
||||||
for _ in $(seq "$READY_TIMEOUT_SECONDS"); do
|
for _ in $(seq "$READY_TIMEOUT_SECONDS"); do
|
||||||
@ -113,36 +139,97 @@ start_host() {
|
|||||||
echo "log $LOG_FILE"
|
echo "log $LOG_FILE"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
# The host died rather than started: say so now instead of counting to 120.
|
||||||
|
running_pid >/dev/null || break
|
||||||
printf '.'
|
printf '.'
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "the host did not answer within ${READY_TIMEOUT_SECONDS}s — see $LOG_FILE" >&2
|
echo "the host is not answering on :$SERVER_PORT — see $LOG_FILE" >&2
|
||||||
tail -5 "$LOG_FILE" >&2 || true
|
report_log_errors
|
||||||
exit 1
|
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() {
|
stop_host() {
|
||||||
local pid
|
local pid
|
||||||
|
# `return`, not `exit`: `restart` on a stopped host must still go on to start it.
|
||||||
if ! pid="$(running_pid)"; then
|
if ! pid="$(running_pid)"; then
|
||||||
echo "not running"
|
echo "not running"
|
||||||
rm -f "$PID_FILE"
|
rm -f "$PID_FILE"
|
||||||
exit 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# npm run dev spawns the server and vite; killing the group takes all of them.
|
# npm run dev spawns concurrently, which spawns the server and vite. Killing
|
||||||
kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true
|
# 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
|
for _ in $(seq 10); do
|
||||||
kill -0 "$pid" 2>/dev/null || break
|
kill -0 "$pid" 2>/dev/null || break
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
done
|
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"
|
rm -f "$PID_FILE"
|
||||||
echo "stopped"
|
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() {
|
show_status() {
|
||||||
local pid
|
local pid
|
||||||
if pid="$(running_pid)"; then
|
if pid="$(running_pid)"; then
|
||||||
@ -151,11 +238,13 @@ show_status() {
|
|||||||
echo "stopped"
|
echo "stopped"
|
||||||
fi
|
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"
|
local installed="$HOME/.claude-code-ui/plugins/cloudcli-plugin-taskwork/manifest.json"
|
||||||
if [ -f "$installed" ]; then
|
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
|
else
|
||||||
echo "plugin not installed — ./scripts/link-plugin.sh"
|
echo "plugin not installed — ./scripts/link-plugin.sh"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user