feat(zstart): uvicorn/startApp support + missing-venv warning + fix --detached hang (#12)

* feat(zstart): support uvicorn/ASGI apps via a startApp config field

Python projects could only be started as `python -m <startModule>`, so
FastAPI/ASGI apps that run under uvicorn (like evo-ai:
`uvicorn app.main:app`) couldn't be started by zstart in either port.

Add an optional `startApp` field. When set, zstart runs
`uvicorn <startApp> --host <bind-host> --port <ports.dev> --reload` via
the venv python's -m (no PATH juggling), integrating zstart's existing
bind-host and dev-port handling. startApp takes precedence over
startModule; a python project still needs one or the other. Applied to
bash and PowerShell, documented in the README + example configs.

* feat(zstart): warn when falling back to system python (no project venv)

A python project with no .venv (or only a Windows .venv when on WSL)
silently ran under the system interpreter, which usually lacks the
project's deps - producing a cryptic ModuleNotFoundError far from the
cause. Now zstart prints a clear warning naming the missing venv and the
one-liner to create it, before starting. Bash + PowerShell.

* fix(bash): zstart --detached no longer hangs on the tracking FIFO

Detached mode forked the long-lived server while it still inherited the
ztokens tracking fds (the capture FIFO on 1/2, saved stdout/stderr on
3/4). The parent's EXIT-trap footer runs `tee` on that FIFO and waits for
EOF, which never came while the server held it open - so `zstart
--detached` (and zstartd / zrestart --detached) hung instead of
returning. detach() now redirects stdin<-/dev/null, stdout/stderr->log
and closes fd 3/4 before exec'ing the server. Verified on WSL: detached
returns in 0s and the server still boots.

* fix(zstart): git-pull pre-step can't hang on a credential prompt

start.gitPull ran `git pull --ff-only` before starting the server; in an
environment with no cached git credentials (e.g. WSL against an HTTPS
GitHub remote) git prompted "Username for 'https://github.com':" and the
whole start blocked on stdin. Run the pull with GIT_TERMINAL_PROMPT=0 so
it fails fast, log a clear "auto-pull skipped" note, and start with the
current checkout. Bash + PowerShell.
This commit is contained in:
kellymichels 2026-07-25 22:17:14 -05:00 committed by GitHub
parent 50b7c81736
commit 32b9f7fba9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 78 additions and 26 deletions

View File

@ -72,6 +72,7 @@ The example config ships with sample projects named by their kind — `pyapp`, `
"kind": "python", // python | vite | nextjs | edge | docker "kind": "python", // python | vite | nextjs | edge | docker
"localRoot": "C:\\dev\\myapp", // project folder on this machine "localRoot": "C:\\dev\\myapp", // project folder on this machine
"startModule": "myapp.main", // python kind: runs "python -m myapp.main" "startModule": "myapp.main", // python kind: runs "python -m myapp.main"
// "startApp": "app.main:app", // ...or, for ASGI/FastAPI: uvicorn app.main:app --port <dev> --reload
"ports": { "dev": 8080, "prod": 3000 }, // local dev port / direct server port "ports": { "dev": 8080, "prod": 3000 }, // local dev port / direct server port
"domain": "www.myapp.com", // public domain (health checks + verification) "domain": "www.myapp.com", // public domain (health checks + verification)
"start": { // optional zstart pre-steps "start": { // optional zstart pre-steps
@ -135,7 +136,7 @@ The `.cmd` wrappers are the everyday interface. Every command takes one or more
zstart <project> [<project> ...] [-Port N] [-BindHost <host>] [-Detached] zstart <project> [<project> ...] [-Port N] [-BindHost <host>] [-Detached]
``` ```
Starts each project's dev server using the handler for its `kind`: **python** runs `python -m <startModule>` (preferring the project's `.venv`), **vite** runs `npm run dev -- --host --port`, **nextjs** runs `npm run dev` with `PORT` set. Runs `npm install` automatically if `node_modules` is missing. A project's optional `start` config block runs first — `gitPull` fast-forwards the checkout and `env` sets process environment variables. Two more opt-in conveniences: if the project has a `motd/` folder of `.txt` files, one is shown (rotating) at startup; if it has `scripts/build_version_tool.py`, the build number is bumped on each start. Starts each project's dev server using the handler for its `kind`: **python** runs `python -m <startModule>` — or, for an ASGI/FastAPI app, `uvicorn <startApp>` (e.g. `app.main:app`) with the dev port and `--reload` — preferring the project's `.venv`; **vite** runs `npm run dev -- --host --port`, **nextjs** runs `npm run dev` with `PORT` set. Runs `npm install` automatically if `node_modules` is missing. A project's optional `start` config block runs first — `gitPull` fast-forwards the checkout and `env` sets process environment variables. Two more opt-in conveniences: if the project has a `motd/` folder of `.txt` files, one is shown (rotating) at startup; if it has `scripts/build_version_tool.py`, the build number is bumped on each start.
```powershell ```powershell
zstart viteapp # dev server on its configured port zstart viteapp # dev server on its configured port

View File

@ -29,6 +29,7 @@
"kind": "python", "kind": "python",
"localRoot": "/home/youruser/code/pyapp", "localRoot": "/home/youruser/code/pyapp",
"startModule": "pyapp.main", "startModule": "pyapp.main",
"_startApp_note": "ASGI/FastAPI app? Use \"startApp\": \"app.main:app\" instead of startModule (runs uvicorn --port <dev> --reload).",
"ports": { "dev": 8080 }, "ports": { "dev": 8080 },
"domain": "pyapp.yourdomain.com", "domain": "pyapp.yourdomain.com",
"start": { "start": {

View File

@ -8,8 +8,9 @@
# Usage: # Usage:
# zstart <project> [<project> ...] [--port N] [--bind-host host] [--detached] # zstart <project> [<project> ...] [--port N] [--bind-host host] [--detached]
# #
# Handlers by kind: python (python -m <startModule>, prefers .venv), # Handlers by kind: python (python -m <startModule>, or uvicorn <startApp> for
# vite (npm run dev -- --host --port), nextjs (npm run dev with PORT). # ASGI/FastAPI apps; prefers .venv), vite (npm run dev -- --host --port),
# nextjs (npm run dev with PORT).
# Detached servers log to /tmp/zstart-<project>.log ; stop them with zkill. # Detached servers log to /tmp/zstart-<project>.log ; stop them with zkill.
set -uo pipefail set -uo pipefail
@ -43,7 +44,13 @@ warn_if_privileged_port() { # <port>
detach() { # <key> <workdir> <cmd...> detach() { # <key> <workdir> <cmd...>
local key="$1" dir="$2"; shift 2 local key="$1" dir="$2"; shift 2
local log="/tmp/zstart-${key}.log" local log="/tmp/zstart-${key}.log"
( cd "$dir" && nohup "$@" >"$log" 2>&1 & ) # Sever every fd tied to this shell before exec'ing the long-lived server:
# stdin<-/dev/null, stdout/stderr->log, and CLOSE the ztokens tracking fds
# (3/4 = saved stdout/stderr; 1/2 fed the capture FIFO). If the server keeps
# any open, the parent's EXIT-trap footer (a `tee` on the FIFO) never gets
# EOF and `zstart --detached` hangs instead of returning.
( cd "$dir" && exec </dev/null >"$log" 2>&1 3>&- 4>&-; exec nohup "$@" ) &
disown 2>/dev/null || true
ok "Started in detached mode (logs: $log)." ok "Started in detached mode (logs: $log)."
dim "Use zkill $key to stop it." dim "Use zkill $key to stop it."
} }
@ -59,19 +66,22 @@ start_prep() { # <key>
dim " env $name=$val" dim " env $name=$val"
done < <(jq -r --arg k "$key" '.projects[$k].start.env // {} | to_entries[] | "\(.key)\t\(.value)"' "$ZCONFIG") done < <(jq -r --arg k "$key" '.projects[$k].start.env // {} | to_entries[] | "\(.key)\t\(.value)"' "$ZCONFIG")
if [ "$(zproj "$key" .start.gitPull)" = "true" ] && [ -d "$root/.git" ]; then if [ "$(zproj "$key" .start.gitPull)" = "true" ] && [ -d "$root/.git" ]; then
local last # GIT_TERMINAL_PROMPT=0 so a repo that needs credentials fails fast instead
last="$( (cd "$root" && git pull --ff-only 2>&1) | tail -1 )" # of blocking the server start on an interactive "Username for ..." prompt.
dim " git pull: $last" local out rc
(cd "$root" && git rev-parse HEAD >/dev/null 2>&1) || warn " Auto-pull failed - run 'git pull' manually if needed." out="$( (cd "$root" && GIT_TERMINAL_PROMPT=0 git pull --ff-only 2>&1) )"; rc=$?
dim " git pull: $(printf '%s\n' "$out" | tail -1)"
[ "$rc" -eq 0 ] || warn " Auto-pull skipped (exit $rc) - starting with the current checkout. (git needs credentials here, or set start.gitPull=false)"
fi fi
} }
start_python() { # <key> <port> start_python() { # <key> <port>
local key="$1" port="$2" root module exe bv="" local key="$1" port="$2" root module app exe bv="" run_cmd=() what using_venv=1
root="$(zproj_root "$key")" root="$(zproj_root "$key")"
module="$(zproj "$key" .startModule)" module="$(zproj "$key" .startModule)"
app="$(zproj "$key" .startApp)"
[ -d "$root" ] || { err "Project root not found: $root"; return 1; } [ -d "$root" ] || { err "Project root not found: $root"; return 1; }
[ -n "$module" ] || { err "Project '$key' (kind=python) needs 'startModule' in zconfig.json (e.g. \"startModule\": \"pyapp.main\")."; return 1; } [ -n "$module" ] || [ -n "$app" ] || { err "Project '$key' (kind=python) needs 'startModule' (python -m ...) or 'startApp' (uvicorn app:app) in zconfig.json."; return 1; }
# Prefer the project venv. The Scripts/python.exe branch is a *Windows* venv # Prefer the project venv. The Scripts/python.exe branch is a *Windows* venv
# layout — only runnable from a Windows-family shell (Git Bash/MSYS/Cygwin). # layout — only runnable from a Windows-family shell (Git Bash/MSYS/Cygwin).
@ -79,8 +89,8 @@ start_python() { # <key> <port>
# executable but can't exec, so guard that branch to fall through to python3. # executable but can't exec, so guard that branch to fall through to python3.
if [ -x "$root/.venv/bin/python" ]; then exe="$root/.venv/bin/python" if [ -x "$root/.venv/bin/python" ]; then exe="$root/.venv/bin/python"
elif [ -x "$root/.venv/Scripts/python.exe" ] && [[ "$OSTYPE" == msys* || "$OSTYPE" == cygwin* ]]; then exe="$root/.venv/Scripts/python.exe" elif [ -x "$root/.venv/Scripts/python.exe" ] && [[ "$OSTYPE" == msys* || "$OSTYPE" == cygwin* ]]; then exe="$root/.venv/Scripts/python.exe"
elif command -v python3 >/dev/null 2>&1; then exe="python3" elif command -v python3 >/dev/null 2>&1; then exe="python3"; using_venv=0
else exe="python"; fi else exe="python"; using_venv=0; fi
# Optional convention: scripts/build_version_tool.py bumps the version on dev start. # Optional convention: scripts/build_version_tool.py bumps the version on dev start.
if [ -f "$root/scripts/build_version_tool.py" ]; then if [ -f "$root/scripts/build_version_tool.py" ]; then
@ -88,8 +98,25 @@ start_python() { # <key> <port>
[ -n "$bv" ] || bv="$(cd "$root" && "$exe" scripts/build_version_tool.py get 2>/dev/null | tail -1)" [ -n "$bv" ] || bv="$(cd "$root" && "$exe" scripts/build_version_tool.py get 2>/dev/null | tail -1)"
fi fi
# startApp (uvicorn ASGI target, e.g. app.main:app) takes precedence over
# startModule (python -m ...). uvicorn is run via the venv python's -m so no
# PATH juggling is needed, and it gets zstart's bind-host + dev port.
if [ -n "$app" ]; then
run_cmd=("$exe" -m uvicorn "$app" --host "$bind_host" --port "$port" --reload)
what="uvicorn $app"
else
run_cmd=("$exe" -m "$module")
what="python -m $module"
fi
printf '\n'; info "=== zstart ($(zproj "$key" .label)) ===" printf '\n'; info "=== zstart ($(zproj "$key" .label)) ==="
info "Starting python -m $module on port $port..." # No project venv found - warn, since the system interpreter usually lacks the
# app's deps (the failure would otherwise be a cryptic ModuleNotFoundError).
if [ "$using_venv" -eq 0 ]; then
warn "No project venv at $root/.venv - using system '$exe' (its deps may be missing)."
dim " Create one: (cd \"$root\" && python3 -m venv .venv && .venv/bin/pip install -e .)"
fi
info "Starting $what on port $port..."
[ -n "$bv" ] && note "Build Version: $bv" [ -n "$bv" ] && note "Build Version: $bv"
show_project_motd "$root" show_project_motd "$root"
dim "Press Ctrl+C to stop the server" dim "Press Ctrl+C to stop the server"
@ -97,9 +124,9 @@ start_python() { # <key> <port>
printf '\n' printf '\n'
if [ "$detached" -eq 1 ]; then if [ "$detached" -eq 1 ]; then
detach "$key" "$root" "$exe" -m "$module" detach "$key" "$root" "${run_cmd[@]}"
else else
( cd "$root" && exec "$exe" -m "$module" ) ( cd "$root" && exec "${run_cmd[@]}" )
fi fi
} }

View File

@ -24,6 +24,7 @@
"kind": "python", "kind": "python",
"localRoot": "C:\\YourRoot\\pyapp", "localRoot": "C:\\YourRoot\\pyapp",
"startModule": "pyapp.main", "startModule": "pyapp.main",
"_startApp_note": "ASGI/FastAPI app? Use \"startApp\": \"app.main:app\" instead of startModule (runs uvicorn --port <dev> --reload).",
"ports": { "dev": 8080 }, "ports": { "dev": 8080 },
"domain": "pyapp.yourdomain.com", "domain": "pyapp.yourdomain.com",
"start": { "start": {

View File

@ -12,8 +12,9 @@
# zstart viteapp -Port 3000 # zstart viteapp -Port 3000
# zstart pyapp nextapp -Detached # zstart pyapp nextapp -Detached
# #
# Handlers by kind: python (python -m <startModule>, prefers .venv), # Handlers by kind: python (python -m <startModule>, or uvicorn <startApp> for
# vite (npm run dev -- --host --port), nextjs (npm run dev with PORT). # ASGI/FastAPI apps; prefers .venv), vite (npm run dev -- --host --port),
# nextjs (npm run dev with PORT).
# #
param( param(
[Parameter(Position = 0, ValueFromRemainingArguments = $true)] [Parameter(Position = 0, ValueFromRemainingArguments = $true)]
@ -45,16 +46,18 @@ function Test-PortNeedsAdmin {
} }
function Start-PythonProject { function Start-PythonProject {
param([string]$Key, $Proj, [int]$ListenPort, [bool]$RunDetached) param([string]$Key, $Proj, [int]$ListenPort, [bool]$RunDetached, [string]$HostBind = "127.0.0.1")
$root = $Proj.localRoot $root = $Proj.localRoot
if (-not (Test-Path -LiteralPath $root)) { throw "Project root not found: $root" } if (-not (Test-Path -LiteralPath $root)) { throw "Project root not found: $root" }
$module = $Proj.startModule $module = $Proj.startModule
if (-not $module) { $app = $Proj.startApp
throw "Project '$Key' (kind=python) needs 'startModule' in zconfig.json (e.g. `"startModule`": `"pyapp.main`" runs 'python -m pyapp.main')." if (-not $module -and -not $app) {
throw "Project '$Key' (kind=python) needs 'startModule' (python -m ...) or 'startApp' (uvicorn app:app) in zconfig.json."
} }
Set-Location -LiteralPath $root Set-Location -LiteralPath $root
$venvPython = Join-Path $root ".venv\Scripts\python.exe" $venvPython = Join-Path $root ".venv\Scripts\python.exe"
$exe = if (Test-Path -LiteralPath $venvPython) { $venvPython } else { "python" } $usingVenv = Test-Path -LiteralPath $venvPython
$exe = if ($usingVenv) { $venvPython } else { "python" }
# Optional convention: if the project ships scripts/build_version_tool.py, # Optional convention: if the project ships scripts/build_version_tool.py,
# bump (or at least read) the build version on every dev start. # bump (or at least read) the build version on every dev start.
@ -77,9 +80,24 @@ function Start-PythonProject {
} }
} }
# startApp (uvicorn ASGI target, e.g. app.main:app) takes precedence over
# startModule (python -m ...). uvicorn runs via the venv python's -m, and
# gets zstart's bind-host + dev port.
if ($app) {
$runArgs = @("-m", "uvicorn", $app, "--host", $HostBind, "--port", "$ListenPort", "--reload")
$what = "uvicorn $app"
} else {
$runArgs = @("-m", $module)
$what = "python -m $module"
}
Write-Host "" Write-Host ""
Write-Host "=== zstart ($($Proj.label)) ===" -ForegroundColor Cyan Write-Host "=== zstart ($($Proj.label)) ===" -ForegroundColor Cyan
Write-Host "Starting python -m $module on port $ListenPort..." -ForegroundColor Cyan if (-not $usingVenv) {
Write-Host "No project venv at $root\.venv - using system 'python' (its deps may be missing)." -ForegroundColor Yellow
Write-Host " Create one: python -m venv .venv; .\.venv\Scripts\pip install -e ." -ForegroundColor DarkGray
}
Write-Host "Starting $what on port $ListenPort..." -ForegroundColor Cyan
if (-not [string]::IsNullOrWhiteSpace($buildVersion)) { if (-not [string]::IsNullOrWhiteSpace($buildVersion)) {
Write-Host "Build Version: $buildVersion" -ForegroundColor Magenta Write-Host "Build Version: $buildVersion" -ForegroundColor Magenta
} }
@ -91,11 +109,11 @@ function Start-PythonProject {
Write-Host "" Write-Host ""
if ($RunDetached) { if ($RunDetached) {
Start-Process -FilePath $exe -ArgumentList "-m", $module -WorkingDirectory $root | Out-Null Start-Process -FilePath $exe -ArgumentList $runArgs -WorkingDirectory $root | Out-Null
Write-Host "Started in detached mode." -ForegroundColor Green Write-Host "Started in detached mode." -ForegroundColor Green
Write-Host "Use zkill $Key to stop it." -ForegroundColor DarkGray Write-Host "Use zkill $Key to stop it." -ForegroundColor DarkGray
} else { } else {
& $exe -m $module & $exe @runArgs
} }
} }
@ -181,14 +199,18 @@ function Invoke-ProjectStartPrep {
} }
if ($Proj.start.gitPull -and (Test-Path (Join-Path $Proj.localRoot ".git"))) { if ($Proj.start.gitPull -and (Test-Path (Join-Path $Proj.localRoot ".git"))) {
Push-Location -LiteralPath $Proj.localRoot Push-Location -LiteralPath $Proj.localRoot
# GIT_TERMINAL_PROMPT=0 so a repo that needs credentials fails fast
# instead of blocking the server start on a "Username for ..." prompt.
$prev = $env:GIT_TERMINAL_PROMPT; $env:GIT_TERMINAL_PROMPT = "0"
try { try {
$pullOut = git pull --ff-only 2>&1 $pullOut = git pull --ff-only 2>&1
$last = ($pullOut | Select-Object -Last 1) $last = ($pullOut | Select-Object -Last 1)
Write-Host " git pull: $last" -ForegroundColor DarkGray Write-Host " git pull: $last" -ForegroundColor DarkGray
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
Write-Host " Auto-pull failed - run 'git pull' manually if needed." -ForegroundColor Yellow Write-Host " Auto-pull skipped - starting with the current checkout. (git needs credentials here, or set start.gitPull=false)" -ForegroundColor Yellow
} }
} finally { } finally {
$env:GIT_TERMINAL_PROMPT = $prev
Pop-Location Pop-Location
} }
} }
@ -201,7 +223,7 @@ foreach ($key in $Projects) {
Invoke-ProjectStartPrep -Proj $proj Invoke-ProjectStartPrep -Proj $proj
switch ([string]$proj.kind) { switch ([string]$proj.kind) {
"python" { Start-PythonProject -Key $key -Proj $proj -ListenPort $devPort -RunDetached $Detached.IsPresent } "python" { Start-PythonProject -Key $key -Proj $proj -ListenPort $devPort -HostBind $BindHost -RunDetached $Detached.IsPresent }
"vite" { Start-ViteProject -Key $key -Proj $proj -ListenPort $devPort -HostBind $BindHost -RunDetached $Detached.IsPresent } "vite" { Start-ViteProject -Key $key -Proj $proj -ListenPort $devPort -HostBind $BindHost -RunDetached $Detached.IsPresent }
"nextjs" { Start-NextProject -Key $key -Proj $proj -ListenPort $devPort -RunDetached $Detached.IsPresent } "nextjs" { Start-NextProject -Key $key -Proj $proj -ListenPort $devPort -RunDetached $Detached.IsPresent }
default { default {