Skip to content

Commit 0ece218

Browse files
committed
Fix errexit start lie, scan HTTPS/IPv6 misses, failed-state visibility, and clone hangs
1 parent 4e74578 commit 0ece218

3 files changed

Lines changed: 264 additions & 35 deletions

File tree

bin/devkit

Lines changed: 144 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ CADDYFILE="$DEVKIT_HOME/Caddyfile"
3434
DASHBOARD="$DEVKIT_HOME/dashboard.html"
3535
PIDS_DIR="$DEVKIT_HOME/pids"
3636
LOGS_DIR="$DEVKIT_HOME/logs"
37+
# Per-app last-start outcome markers. A start that fails drops a marker here so `list`
38+
# (and the app/MCP that read it) can show a distinct "failed" state with a pointer to the
39+
# logs, instead of a plain "stopped" that's indistinguishable from never-started.
40+
STATUS_DIR="$DEVKIT_HOME/state"
3741
LOCK_DIR="$DEVKIT_HOME/.registry.lock"
3842
# Stamp recording the registry fingerprint the generated config was last built from.
3943
# Lets every command cheaply detect an out-of-band apps.json edit and self-heal.
@@ -73,7 +77,7 @@ else
7377
fi
7478

7579
ensure_registry_dir() {
76-
mkdir -p "$DEVKIT_HOME" "$PIDS_DIR" "$LOGS_DIR" 2>/dev/null || true
80+
mkdir -p "$DEVKIT_HOME" "$PIDS_DIR" "$LOGS_DIR" "$STATUS_DIR" 2>/dev/null || true
7781
}
7882

7983
# One-time move of an old-style registry into the new default location (~/.devkit).
@@ -252,6 +256,22 @@ assert_unique() {
252256

253257
pid_file() { echo "$PIDS_DIR/$1.pid"; }
254258
log_file() { echo "$LOGS_DIR/$1.log"; }
259+
fail_file() { echo "$STATUS_DIR/$1.fail"; }
260+
261+
# Record / clear / read the outcome of the most recent `devkit start <name>`. The marker
262+
# file holds "<epoch>\t<reason>" so a UI can show when it failed and why, and always point
263+
# at the log. Best-effort: a read-only registry dir must never make start itself fail.
264+
mark_start_failed() {
265+
local name="$1" reason="${2:-start failed}"
266+
mkdir -p "$STATUS_DIR" 2>/dev/null || return 0
267+
printf '%s\t%s\n' "$(date +%s 2>/dev/null || echo 0)" "$reason" > "$(fail_file "$name")" 2>/dev/null || true
268+
}
269+
clear_start_failed() { rm -f "$(fail_file "$1")" 2>/dev/null || true; }
270+
start_failed_reason() {
271+
local f; f=$(fail_file "$1")
272+
[[ -f "$f" ]] || return 1
273+
cut -f2- < "$f" 2>/dev/null
274+
}
255275
app_port() { jq -r --arg n "$1" '.apps[] | select(.name==$n) | .port // empty' "$APPS_JSON"; }
256276

257277
canonical_path() {
@@ -289,7 +309,7 @@ wait_for_pid_exit() {
289309
local i=0
290310
while kill -0 "$pid" 2>/dev/null && (( i < attempts )); do
291311
sleep 0.1
292-
(( i++ ))
312+
i=$((i + 1))
293313
done
294314
! kill -0 "$pid" 2>/dev/null
295315
}
@@ -304,7 +324,7 @@ wait_for_port_state() {
304324
[[ "$desired" == "free" ]] && return 0
305325
fi
306326
sleep 0.1
307-
(( i++ ))
327+
i=$((i + 1))
308328
done
309329

310330
if port_is_listening "$port"; then
@@ -410,6 +430,26 @@ is_running() {
410430
[[ -n "$port" && -n "$path" && -n "$(listener_pids_for_app "$port" "$path" "$logfile")" ]]
411431
}
412432

433+
# Single source of truth for an app's display state, shared by `list` (text + --json) so
434+
# the human and machine views can never disagree. Returns one of:
435+
# running — a matching listener is up
436+
# external — not lifecycle-managed by devkit (we don't claim to know)
437+
# failed — last `devkit start` failed and it's still not up (points the user at logs)
438+
# stopped — managed, down, no recorded failure (identical to never-started)
439+
app_state() {
440+
local name="$1" managed="$2"
441+
[[ "$managed" == "pm2" ]] && managed="devkit"
442+
case "$managed" in
443+
external) echo "external"; return 0 ;;
444+
devkit)
445+
if is_running "$name"; then echo "running"; return 0; fi
446+
[[ -f "$(fail_file "$name")" ]] && { echo "failed"; return 0; }
447+
echo "stopped"
448+
;;
449+
*) echo "stopped" ;;
450+
esac
451+
}
452+
413453
# ---------- commands ----------
414454

415455
cmd_register() {
@@ -510,6 +550,7 @@ cmd_rename() {
510550
[[ -f "$(log_file "$old")" ]] && mv "$(log_file "$old")" "$(log_file "$new")" 2>/dev/null || true
511551
if want_has "$old"; then ensure_supervisor_dirs; mv "$WANTS_DIR/$old" "$WANTS_DIR/$new" 2>/dev/null || true; fi
512552
[[ -f "$SUPERVISOR_STATE_DIR/$old" ]] && mv "$SUPERVISOR_STATE_DIR/$old" "$SUPERVISOR_STATE_DIR/$new" 2>/dev/null || true
553+
[[ -f "$(fail_file "$old")" ]] && mv "$(fail_file "$old")" "$(fail_file "$new")" 2>/dev/null || true
513554

514555
local new_host="$new.$(tld)"
515556
local tmp; tmp=$(mktemp)
@@ -609,6 +650,7 @@ cmd_remove() {
609650
_stop_by_name "$name" "$port" "$path" || die "$name did not stop cleanly before remove"
610651
fi
611652
want_clear "$name"
653+
clear_start_failed "$name"
612654
rm -f "$(pid_file "$name")" "$(log_file "$name")"
613655
local tmp; tmp=$(mktemp)
614656
jq --arg n "$name" '.apps |= map(select(.name != $n))' "$APPS_JSON" > "$tmp"
@@ -619,17 +661,48 @@ cmd_remove() {
619661

620662
cmd_list() {
621663
ensure_registry
664+
local json_mode=false
665+
[[ "${1:-}" == "--json" ]] && json_mode=true
666+
667+
if $json_mode; then
668+
# Machine-readable view for the app / MCP, so they consume devkit's own state instead
669+
# of re-deriving it (and drifting). One JSON object per app, including the failed-start
670+
# reason and the log path so a UI can link straight to the dead end's cause.
671+
local objs=()
672+
local name port hostname path managed
673+
# Fields are \x1f-separated (not \t): tab is IFS-whitespace, so an empty middle field
674+
# — e.g. a path-less external app — would collapse and shift every later column.
675+
while IFS=$'\037' read -r name port hostname path managed; do
676+
local state reason
677+
state=$(app_state "$name" "$managed")
678+
reason=""
679+
[[ "$state" == "failed" ]] && reason=$(start_failed_reason "$name" || true)
680+
objs+=("$(jq -n \
681+
--arg name "$name" --argjson port "${port:-0}" --arg host "$hostname" \
682+
--arg url "$(url_for "$hostname")" --arg state "$state" \
683+
--arg path "$path" --arg managed "$managed" \
684+
--arg reason "$reason" --arg log "$(log_file "$name")" \
685+
'{name:$name, port:$port, hostname:$host, url:$url, state:$state,
686+
path:(if $path=="" then null else $path end), managedBy:$managed,
687+
failReason:(if $reason=="" then null else $reason end),
688+
logFile:(if $state=="failed" then $log else null end)}')")
689+
done < <(jq -r '.apps[] | [.name, (.port|tostring), .hostname, (.path // ""), .managedBy] | join("\u001f")' "$APPS_JSON")
690+
if (( ${#objs[@]} == 0 )); then printf '[]\n'; else printf '%s\n' "${objs[@]}" | jq -s '.'; fi
691+
return 0
692+
fi
693+
622694
printf "%-18s %-7s %-32s %-9s %s\n" NAME PORT URL STATE PATH
623-
while IFS=$'\t' read -r name port hostname path managed; do
624-
local state="stopped"
625-
[[ "$managed" == "pm2" ]] && managed="devkit"
626-
if [[ "$managed" == "devkit" ]]; then
627-
is_running "$name" && state="running" || state="stopped"
628-
elif [[ "$managed" == "external" ]]; then
629-
state="external"
630-
fi
695+
local failed_any=0
696+
local name port hostname path managed
697+
while IFS=$'\037' read -r name port hostname path managed; do
698+
local state; state=$(app_state "$name" "$managed")
699+
[[ "$state" == "failed" ]] && failed_any=1
631700
printf "%-18s %-7s %-32s %-9s %s\n" "$name" "$port" "$(url_for "$hostname")" "$state" "${path:-—}"
632-
done < <(jq -r '.apps[] | [.name, (.port|tostring), .hostname, (.path // ""), .managedBy] | @tsv' "$APPS_JSON")
701+
done < <(jq -r '.apps[] | [.name, (.port|tostring), .hostname, (.path // ""), .managedBy] | join("\u001f")' "$APPS_JSON")
702+
if (( failed_any )); then
703+
echo
704+
echo " one or more apps failed their last start — inspect with: devkit logs <name>"
705+
fi
633706
}
634707

635708
cmd_show() {
@@ -698,13 +771,28 @@ scan_probe_to_file() {
698771
local hdr body code ctype scheme=http head title is_html=0 cmd cwd
699772

700773
hdr=$(mktemp); body=$(mktemp)
701-
code=$(curl -s -L --max-redirs 3 --connect-timeout "$SCAN_CONNECT_TIMEOUT" -m "$SCAN_TIMEOUT" \
702-
-o "$body" -D "$hdr" -w '%{http_code}' "http://127.0.0.1:$port/" 2>/dev/null) || code=000
703-
if [[ "$code" == 000 ]]; then
704-
code=$(curl -sk -L --max-redirs 3 --connect-timeout "$SCAN_CONNECT_TIMEOUT" -m "$SCAN_TIMEOUT" \
705-
-o "$body" -D "$hdr" -w '%{http_code}' "https://127.0.0.1:$port/" 2>/dev/null) || code=000
706-
scheme=https
707-
fi
774+
# Probe HTTP and HTTPS on both IPv4 (127.0.0.1) and IPv6 ([::1]), and trust curl's
775+
# reported %{http_code} rather than its exit status. Two reasons this matters:
776+
# * Some servers (notably a self-signed TLS handshake teardown) make curl exit
777+
# non-zero AFTER returning a valid status line. The old `|| code=000` clobbered
778+
# that real 200 with 000 and the app was silently dropped.
779+
# * Servers that bind ::1 ONLY (Vite v6's default) are invisible to a 127.0.0.1-only
780+
# probe even though lsof already enumerated them — enumerated, then dropped.
781+
# `|| true` lives INSIDE the command substitution so a non-zero curl can't abort this
782+
# backgrounded probe under `set -e`, while the captured http_code is preserved.
783+
local _scheme _host _c
784+
local _args=()
785+
code=000
786+
for _scheme in http https; do
787+
for _host in 127.0.0.1 '[::1]'; do
788+
_args=(-s -L --max-redirs 3 --connect-timeout "$SCAN_CONNECT_TIMEOUT" -m "$SCAN_TIMEOUT"
789+
-o "$body" -D "$hdr" -w '%{http_code}')
790+
[[ "$_scheme" == https ]] && _args+=(-k)
791+
_c=$(curl "${_args[@]}" "$_scheme://$_host:$port/" 2>/dev/null || true)
792+
[[ -n "$_c" ]] || _c=000
793+
if [[ "$_c" != 000 ]]; then code="$_c"; scheme="$_scheme"; break 2; fi
794+
done
795+
done
708796
if [[ "$code" == 000 ]]; then rm -f "$hdr" "$body"; return 0; fi
709797

710798
# `|| true`: under `set -euo pipefail` a grep with no match exits 1, and because this
@@ -890,9 +978,11 @@ cmd_start() {
890978
port=$(jq -r --arg n "$name" '.apps[] | select(.name==$n) | .port' "$APPS_JSON")
891979

892980
if [[ -z "$path" || ! -d "$path" ]]; then
981+
mark_start_failed "$name" "path missing or not a directory: $path"
893982
echo "devkit: $name — path missing or not a directory: $path" >&2; return 1
894983
fi
895984
if [[ -z "$cmd" || "$cmd" == "null" ]]; then
985+
mark_start_failed "$name" "no startCmd registered"
896986
echo "devkit: $name — no startCmd registered" >&2; return 1
897987
fi
898988

@@ -901,10 +991,12 @@ cmd_start() {
901991

902992
# Stop any existing tracked process before starting a new one
903993
_stop_by_name "$name" "$port" "$path_for_app" || {
994+
mark_start_failed "$name" "failed to stop cleanly before restart"
904995
echo "devkit: $name — failed to stop cleanly before restart" >&2
905996
return 1
906997
}
907998
if ! wait_for_port_state "$port" free 20; then
999+
mark_start_failed "$name" "port $port is still in use by another process"
9081000
echo "devkit: $name — port $port is still in use by another process" >&2
9091001
return 1
9101002
fi
@@ -935,7 +1027,7 @@ cmd_start() {
9351027
pid=$(cat "$pf" 2>/dev/null || echo "")
9361028
[[ -n "$pid" ]] && break
9371029
sleep 0.1
938-
(( i++ ))
1030+
i=$((i + 1))
9391031
done
9401032

9411033
# Wait for the app to bind its port, but fail fast if the launched process exits first
@@ -947,12 +1039,17 @@ cmd_start() {
9471039
if [[ -n "$(listener_pids_on_port "$port" 2>/dev/null)" ]]; then bound=true; break; fi
9481040
if [[ -n "$pid" ]] && ! kill -0 "$pid" 2>/dev/null; then break; fi
9491041
sleep 0.1
950-
(( waited++ ))
1042+
waited=$((waited + 1))
9511043
done
9521044
if ! $bound; then
9531045
_stop_by_name "$name" "$port" "$path_for_app" >/dev/null 2>&1 || true
9541046
local hint="check logs: devkit logs $name"
955-
[[ -n "$pid" ]] && ! kill -0 "$pid" 2>/dev/null && hint="the start command exited immediately — $hint"
1047+
local reason="failed to bind port $port"
1048+
if [[ -n "$pid" ]] && ! kill -0 "$pid" 2>/dev/null; then
1049+
hint="the start command exited immediately — $hint"
1050+
reason="the start command exited immediately before binding port $port"
1051+
fi
1052+
mark_start_failed "$name" "$reason"
9561053
echo "devkit: $name failed to bind port $port$hint" >&2
9571054
return 1
9581055
fi
@@ -964,13 +1061,15 @@ cmd_start() {
9641061
echo "$pid" > "$pf"
9651062
elif [[ -z "$pid" ]] || ! kill -0 "$pid" 2>/dev/null; then
9661063
rm -f "$pf"
1064+
mark_start_failed "$name" "started listening on $port but no stable listener PID was found"
9671065
echo "devkit: $name started listening on $port, but no stable listener PID was found" >&2
9681066
return 1
9691067
fi
9701068

9711069
# Record desired state = up and clear any crash-backoff (this start is healthy).
9721070
want_set "$name"
9731071
rm -f "$SUPERVISOR_STATE_DIR/$name" 2>/dev/null || true
1072+
clear_start_failed "$name" # last start succeeded — drop any stale "failed" marker
9741073

9751074
echo "started $name (pid $pid) → $(url_for "$(jq -r --arg n "$name" '.apps[] | select(.name==$n) | .hostname' "$APPS_JSON")")"
9761075
}
@@ -982,6 +1081,7 @@ cmd_stop() {
9821081
# Desired state = down. Clear it first so the supervisor won't race to restart what
9831082
# the user just asked to stop.
9841083
want_clear "$name"
1084+
clear_start_failed "$name" # an explicit stop is not a failed-start state
9851085
is_running "$name" || { echo "devkit: $name is not running"; return 0; }
9861086
local port path
9871087
port=$(app_port "$name")
@@ -1044,7 +1144,7 @@ cmd_start_all() {
10441144
local i=0 running=0
10451145
for name in "${names[@]}"; do
10461146
( cmd_start "$name" >"$workdir/$i.out" 2>&1; echo $? >"$workdir/$i.rc" ) &
1047-
(( i++ )); (( running++ ))
1147+
i=$((i + 1)); running=$((running + 1))
10481148
if (( running >= conc )); then wait; running=0; fi
10491149
done
10501150
wait
@@ -1053,8 +1153,8 @@ cmd_start_all() {
10531153
for name in "${names[@]}"; do
10541154
[[ -s "$workdir/$idx.out" ]] && cat "$workdir/$idx.out"
10551155
local rc; rc=$(cat "$workdir/$idx.rc" 2>/dev/null || echo 1)
1056-
if [[ "$rc" == 0 ]]; then (( started++ )); else (( failed++ )) || true; fi
1057-
(( idx++ ))
1156+
if [[ "$rc" == 0 ]]; then started=$((started + 1)); else failed=$((failed + 1)); fi
1157+
idx=$((idx + 1))
10581158
done
10591159
rm -rf "$workdir"
10601160
local msg="started $started app(s)"
@@ -1075,10 +1175,10 @@ cmd_stop_all() {
10751175
path=$(app_path "$name")
10761176
if _stop_by_name "$name" "$port" "$path"; then
10771177
echo "stopped $name"
1078-
(( stopped++ ))
1178+
stopped=$((stopped + 1))
10791179
else
10801180
echo "devkit: failed to stop $name" >&2
1081-
(( failed++ )) || true
1181+
failed=$((failed + 1))
10821182
fi
10831183
fi
10841184
done < <(jq -r '.apps[] | select(.managedBy=="devkit") | .name' "$APPS_JSON")
@@ -1104,15 +1204,15 @@ cmd_restart_all() {
11041204
local i=0 running=0
11051205
for name in "${names[@]}"; do
11061206
( cmd_restart "$name" >"$workdir/$i.out" 2>&1; echo $? >"$workdir/$i.rc" ) &
1107-
(( i++ )); (( running++ ))
1207+
i=$((i + 1)); running=$((running + 1))
11081208
if (( running >= conc )); then wait; running=0; fi
11091209
done
11101210
wait
11111211

11121212
local idx=0
11131213
for name in "${names[@]}"; do
11141214
[[ -s "$workdir/$idx.out" ]] && cat "$workdir/$idx.out"
1115-
(( idx++ ))
1215+
idx=$((idx + 1))
11161216
done
11171217
rm -rf "$workdir"
11181218
}
@@ -1164,7 +1264,7 @@ render_dashboard() {
11641264

11651265
local rows=""
11661266
local link_attrs='target="_blank" rel="noopener"'
1167-
while IFS=$'\t' read -r name hostname port managed path repo claude; do
1267+
while IFS=$'\037' read -r name hostname port managed path repo claude; do
11681268
local url; url=$(url_for "$hostname")
11691269
local repo_cell=""
11701270
[[ -n "$repo" && "$repo" != "null" ]] && repo_cell="<a href=\"$repo\" $link_attrs>repo</a>"
@@ -1184,7 +1284,7 @@ render_dashboard() {
11841284
<td><code class=\"copy\" title=\"click to copy\">devkit edit $name</code></td>
11851285
</tr>
11861286
"
1187-
done < <(jq -r '.apps[] | [.name, .hostname, (.port|tostring), .managedBy, (.path // ""), (.repo // ""), (.claudeMd // "")] | @tsv' "$APPS_JSON")
1287+
done < <(jq -r '.apps[] | [.name, .hostname, (.port|tostring), .managedBy, (.path // ""), (.repo // ""), (.claudeMd // "")] | join("\u001f")' "$APPS_JSON")
11881288

11891289
# Atomic write (temp-in-dir + rename), same rationale as render_caddyfile.
11901290
local tmp; tmp=$(mktemp "$DASHBOARD.XXXXXX" 2>/dev/null) || return 1
@@ -1431,7 +1531,16 @@ cmd_clone() {
14311531
return 0
14321532
fi
14331533
mkdir -p "$(dirname "$path")"
1434-
git clone "$repo" "$path"
1534+
# Never let clone hang a non-interactive caller (an agent, a script, clone-all). A
1535+
# private HTTPS repo would otherwise prompt for a username/password on the TTY, and SSH
1536+
# would prompt for a passphrase or unknown-host confirmation — both block forever with
1537+
# no terminal. Disable the prompts so git fails fast with a clear next action instead.
1538+
if ! GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -oBatchMode=yes}" \
1539+
git clone "$repo" "$path"; then
1540+
die "clone failed for $name ($repo)
1541+
If the repo is private, set up access first (e.g. 'gh auth login' or an SSH key), then: devkit clone $name"
1542+
fi
1543+
echo "cloned $name$path"
14351544
}
14361545

14371546
cmd_clone_all() {
@@ -1710,7 +1819,8 @@ Registry:
17101819
devkit rename <old> <new>
17111820
devkit update <name> [--port N] [--cmd C] [--path P] [--desc D] [--managed-by M]
17121821
devkit remove <name>
1713-
devkit list
1822+
devkit list # NAME/PORT/URL/STATE/PATH (STATE: running|stopped|failed|external)
1823+
devkit list --json # machine-readable state for the menu bar app / agents
17141824
devkit show <name>
17151825
devkit scan # find running web apps not yet registered
17161826
devkit scan --json # machine-readable output (for the menu bar app / agents)
@@ -1764,7 +1874,7 @@ case "${1:-help}" in
17641874
rename) shift; cmd_rename "$@" ;;
17651875
update) shift; cmd_update "$@" ;;
17661876
remove) shift; cmd_remove "$@" ;;
1767-
list) cmd_list ;;
1877+
list) shift; cmd_list "$@" ;;
17681878
show) shift; cmd_show "$@" ;;
17691879
scan) shift; cmd_scan "$@" ;;
17701880
reload) cmd_reload ;;

0 commit comments

Comments
 (0)