@@ -34,6 +34,10 @@ CADDYFILE="$DEVKIT_HOME/Caddyfile"
3434DASHBOARD=" $DEVKIT_HOME /dashboard.html"
3535PIDS_DIR=" $DEVKIT_HOME /pids"
3636LOGS_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"
3741LOCK_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.
7377fi
7478
7579ensure_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
253257pid_file () { echo " $PIDS_DIR /$1 .pid" ; }
254258log_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+ }
255275app_port () { jq -r --arg n " $1 " ' .apps[] | select(.name==$n) | .port // empty' " $APPS_JSON " ; }
256276
257277canonical_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
415455cmd_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
620662cmd_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
635708cmd_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
14371546cmd_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