You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/07/05 07:08:53 UTC
(texera) 01/01: refactor(local-dev): unify the CLI surface for agent use (#6138)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-6138-7d21555bceec552a307c0d4572ff0ee8cd477f94
in repository https://gitbox.apache.org/repos/asf/texera.git
commit 37354af00265b4a0e521d2fb46b94efdf17b807f
Author: Yicong Huang <17...@users.noreply.github.com>
AuthorDate: Sun Jul 5 00:08:31 2026 -0700
refactor(local-dev): unify the CLI surface for agent use (#6138)
### What changes were proposed in this PR?
The local-dev CLI grew unevenly, which makes it awkward to drive from
agent CLIs and scripts. This PR unifies the surface:
1. **`--json` on every one-shot subcommand.** `status`/`up`/`down`
already had it; `auto` and `version` now accept it too. The convention
is unchanged: human progress goes to stderr, one machine-readable status
JSON object goes to stdout.
2. **`up <service>` / `down <service>` replace `start`/`stop`.** The
single-service `up` uses the same build knob as the full-stack form:
rebuild + bounce only if the service's source changed since the last
build (default), `--build` forces it, `--skip-build` never builds. It
also handles the amber-sibling dist sharing (`texera-web` /
`computing-unit-master` bounce together) and runs the jOOQ
postgres-readiness check before any sbt build (#6007). The old
`start`/`stop` and the bare `<service>` shorthand now refuse with a
pointer to the new spelling. In the TUI, `u <svc>`, `d <svc>`, `s
<svc>`, and a bare `<svc>` map onto the new forms.
3. **`--skip-build` replaces `--no-build`** (the old flag refuses with a
rename hint).
4. **Every `--json` payload carries `elapsed_seconds`** — the command's
total wall-clock runtime, so agents can log how long an `up`/`auto` took
without wrapping the call in their own timer.
One behavioral fix that falls out of (2): a full-stack `up` with no
selector re-decides (and resets) the persisted deploy-source pointer,
but `up <service>` is a scoped operation, so the startup peek now leaves
the pointer alone when a positional service argument is present — a
single-service bounce on a deployed PR worktree no longer silently
re-points the deployment at the main checkout.
### Any related issues, documentation, discussions?
Closes #6137. The `--help` header and the `status` footer hints are
updated in place; `bin/local-dev/README.md` needed no changes.
### How was this PR tested?
- `bash bin/local-dev/tests/test_local_dev_sh.sh` — 45 passed, 0 failed.
Updated/added cases: unknown subcommand and `up <unknown-service>`
errors, `start`/`stop` refuse with the new spelling, `--no-build`
refuses with the rename hint, full-stack-only knobs (`--fresh`,
`--skip`, selectors) are rejected in the single-service form, at most
one positional service, `cmd_auto` wires `--json`, `version --json` and
`status --json` carry `elapsed_seconds`, and the startup peek guards the
deploy-pointer reset (isolated `TEXERA_LOCAL_DEV_DIR` so the flag-error
`up` invocations never touch a real deployment's state).
- `pytest bin/local-dev/tests/test_local_dev_tui.py` — 43 passed.
- Manual smoke on a live stack: `version --json`, `status --json` (14/14
running, `elapsed_seconds` present), `--help` renders through the new
end-marker `sed` range.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Fable 5)
Co-authored-by: Claude Fable 5 <no...@anthropic.com>
---
bin/local-dev/main.sh | 330 ++++++++++++++++++++++---------
bin/local-dev/tests/test_local_dev_sh.sh | 105 ++++++++--
bin/local-dev/tui.py | 25 +--
3 files changed, 336 insertions(+), 124 deletions(-)
diff --git a/bin/local-dev/main.sh b/bin/local-dev/main.sh
index e1c884699b..1844dfd7cb 100755
--- a/bin/local-dev/main.sh
+++ b/bin/local-dev/main.sh
@@ -33,42 +33,54 @@
# --json, print one machine-readable
# JSON object (no table) and exit 0
# iff every service is running — the
-# contract for agents/scripts.
-# bin/local-dev.sh up [--fresh|--build|--no-build] [--skip=svc1,svc2] [--json]
-# [--worktree=PATH | --branch=NAME]
+# contract for agents/scripts. Every
+# --json payload (all subcommands)
+# carries elapsed_seconds: the
+# command's total wall-clock runtime.
+# bin/local-dev.sh up [service]
+# [--fresh|--build|--skip-build] [--skip=svc1,svc2] [--json]
+# [--worktree=PATH | --branch=NAME]
# Default: skip build if no source/lock
# changes since last build. --build forces
# incremental sbt dist + yarn/bun install.
-# --fresh runs `sbt clean dist`. --no-build
-# skips the build step entirely. --json
-# sends progress to stderr and the final
-# status JSON to stdout.
-# DEPLOY SOURCE: with no selector the stack
-# is built/run from THIS checkout. Point it
-# at a sibling git worktree with
+# --fresh runs `sbt clean dist`.
+# --skip-build skips the build step
+# entirely. --json sends progress to
+# stderr and the final status JSON to
+# stdout.
+# With a service name, act on just that
+# service: rebuild it if its source
+# changed (--build forces, --skip-build
+# skips), then start/bounce it. The
+# single-service form follows the active
+# deployment; --fresh / --skip / the
+# deploy selectors are full-stack only.
+# DEPLOY SOURCE: with no selector a
+# full-stack `up` deploys THIS checkout.
+# Point it at a sibling git worktree with
# --worktree=PATH or --branch=NAME (the
# worktree that has NAME checked out) to
# deploy a PR branch without disturbing the
# main checkout. The choice is persisted, so
-# later status / down / logs / <svc> / auto
-# all act on it (run a plain `up` to return
-# to this checkout). local-dev.sh itself
-# always runs from this checkout — so if the
-# target branch modifies bin/local-dev/**,
-# those tooling changes are NOT in effect;
-# checkout that branch and run its own
-# local-dev.sh instead (a warning is printed
-# when such drift is detected).
-# bin/local-dev.sh down [--skip=svc1,svc2] [--json]
-# stop every non-skipped service
-# (--json: summary JSON on stdout).
-# bin/local-dev.sh start <service> start one service (no rebuild).
-# bin/local-dev.sh stop <service> stop one service.
-# bin/local-dev.sh <service> rebuild only that service incrementally
-# (sbt <Project>/dist), then bounce it.
-# frontend / agent-service are refused
-# (they have their own watch mode).
+# later status / down / logs / up <svc> /
+# auto all act on it (run a plain full-stack
+# `up` to return to this checkout).
+# local-dev.sh itself always runs from this
+# checkout — so if the target branch
+# modifies bin/local-dev/**, those tooling
+# changes are NOT in effect; checkout that
+# branch and run its own local-dev.sh
+# instead (a warning is printed when such
+# drift is detected).
+# bin/local-dev.sh down [service] [--skip=svc1,svc2] [--json]
+# stop every non-skipped service, or just
+# <service> (--json: summary JSON on
+# stdout).
+# bin/local-dev.sh auto [--skip=svc1,svc2] [--json]
+# rebuild + bounce only the services whose
+# source changed since the last build.
# bin/local-dev.sh logs <service> tail this service's log.
+# bin/local-dev.sh version [--json] print the texera version.
# bin/local-dev.sh w | watch [interval] Hands-off monitor: redraw the
# dashboard every <interval>s
# (default 2). No prompt; Ctrl-C
@@ -111,7 +123,7 @@ set -euo pipefail
# bin/local-dev.sh up deploy from this (self) checkout again
#
# The selection is persisted to $STATE_DIR/deploy-source, so every later
-# command (status, logs, down, single-service rebuild, auto) reads it back and
+# command (status, logs, down, single-service up/down, auto) reads it back and
# acts on the SAME deployment. REPO_ROOT below is pointed at the source tree, which
# is what the rest of the script keys every build/run/git operation off of —
# only the handful of tooling-file paths are pinned to SELF_ROOT.
@@ -167,7 +179,7 @@ _worktree_for_branch() {
}
# Deploy source resolution:
-# • Read-only commands (status / down / logs / <svc>) follow whatever the last
+# • Read-only commands (status / down / logs / up <svc>) follow whatever the last
# up/auto deployed — read it back from the persisted pointer. A stale
# pointer (worktree removed/moved) is dropped silently.
# • up / auto re-decide the deployment: --worktree=PATH / --branch=NAME selects
@@ -188,6 +200,14 @@ fi
# dep graph key off the source tree), so peek the args here — cmd_up / cmd_auto
# re-see and no-op the selectors in their own parse loops.
if [[ "${1:-}" == "up" || "${1:-}" == "auto" ]]; then
+ # A positional service arg makes `up` a single-service operation. That form
+ # follows the active deployment (like status / logs) rather than
+ # re-deciding it, so leave the persisted pointer alone.
+ _has_svc_arg=false
+ for _arg in "${@:2}"; do
+ case "$_arg" in -*) ;; *) _has_svc_arg=true ;; esac
+ done
+ if ! $_has_svc_arg; then
# `up` with no selector resets to this checkout; `auto` keeps the pointer
# value already resolved above.
[[ "${1:-}" == "up" ]] && SOURCE_ROOT="$SELF_ROOT"
@@ -221,6 +241,7 @@ if [[ "${1:-}" == "up" || "${1:-}" == "auto" ]]; then
else
printf '%s\n' "$SOURCE_ROOT" > "$DEPLOY_SOURCE_FILE"
fi
+ fi
fi
REPO_ROOT="$SOURCE_ROOT"
@@ -1705,7 +1726,7 @@ build_one_jvm() {
# lets us tell content-vs-mtime apart on the next dirty check.
svc_source_hash "$svc" > "$BUILD_STAMP_DIR/$svc"
tui_ok "$svc: build done"
- # Don't clear the phase yet — the caller (cmd_update_one) will
+ # Don't clear the phase yet — the caller (cmd_up_one) will
# transition us through stop_one/start_one which overwrite it.
# If something else is the caller, the TUI's "phase cleared once
# poller sees running" rule covers us.
@@ -2077,8 +2098,10 @@ emit_status_json() {
rows+=$(printf '{"service":"%s","port":%s,"type":"%s","pid":%s,"state":"%s"}' \
"$svc" "$port" "$type" "$pid" "$state")
done
- printf '{"branch":"%s","sha":"%s","worktree":"%s","source":"%s","running":%d,"total":%d,"services":[%s]}\n' \
- "$branch" "$sha" "$worktree" "$REPO_ROOT" "$n_running" "$n_total" "$rows"
+ # $SECONDS counts from script start, so this is the command's total
+ # wall-clock runtime — every --json payload carries it.
+ printf '{"branch":"%s","sha":"%s","worktree":"%s","source":"%s","running":%d,"total":%d,"elapsed_seconds":%d,"services":[%s]}\n' \
+ "$branch" "$sha" "$worktree" "$REPO_ROOT" "$n_running" "$n_total" "$SECONDS" "$rows"
(( n_running == n_total ))
}
@@ -2171,9 +2194,8 @@ cmd_status() {
printf " ${BOLD}bin/local-dev.sh up${RESET} ${DIM}# bring up the whole stack (build + start)${RESET}\n"
printf " ${BOLD}bin/local-dev.sh down${RESET} ${DIM}# stop every service${RESET}\n"
printf " ${BOLD}bin/local-dev.sh auto${RESET} ${DIM}# rebuild + bounce only the services whose source changed${RESET}\n"
- printf " ${BOLD}bin/local-dev.sh <svc>${RESET} ${DIM}# rebuild that one JVM service and bounce it${RESET}\n"
- printf " ${BOLD}bin/local-dev.sh start <svc>${RESET} ${DIM}# start one service without rebuilding${RESET}\n"
- printf " ${BOLD}bin/local-dev.sh stop <svc>${RESET} ${DIM}# stop one service${RESET}\n"
+ printf " ${BOLD}bin/local-dev.sh up <svc>${RESET} ${DIM}# bring one service up (rebuilds only if its source changed)${RESET}\n"
+ printf " ${BOLD}bin/local-dev.sh down <svc>${RESET} ${DIM}# stop one service${RESET}\n"
printf " ${BOLD}bin/local-dev.sh logs <svc>${RESET} ${DIM}# tail a service's log${RESET}\n"
printf " ${BOLD}bin/local-dev.sh -i${RESET} ${DIM}# open the live TUI (needs Python + textual)${RESET}\n"
printf " ${BOLD}bin/local-dev.sh --help${RESET} ${DIM}# full reference${RESET}\n"
@@ -2185,17 +2207,23 @@ cmd_up() {
FRESH=false
BUILD=auto # auto (skip if no source change) | force | no
JSON_OUT=false
+ local one_svc="" selector_seen=false
while [[ $# -gt 0 ]]; do
case "$1" in
--skip=*) SKIP_LIST="${1#--skip=}" ;;
--fresh) FRESH=true; BUILD=force ;;
--build) BUILD=force ;;
- --no-build) BUILD=no ;;
+ --skip-build) BUILD=no ;;
+ --no-build) tui_err "--no-build was renamed to --skip-build" >&2; exit 2 ;;
--json) JSON_OUT=true ;;
# Deploy-target selectors are resolved at startup (they must precede
# the build.sbt parse); accept and ignore them here.
- --worktree=*|--branch=*) ;;
- *) tui_err "unknown flag: $1" >&2; exit 2 ;;
+ --worktree=*|--branch=*) selector_seen=true ;;
+ -*) tui_err "unknown flag: $1" >&2; exit 2 ;;
+ *) if [[ -n "$one_svc" ]]; then
+ tui_err "up takes at most one service (got '$one_svc' and '$1')" >&2; exit 2
+ fi
+ one_svc="$1" ;;
esac
shift
done
@@ -2206,6 +2234,20 @@ cmd_up() {
# so a non-interactive caller still sees progress live on the side stream.
if $JSON_OUT; then exec 3>&1 1>&2; fi
+ # Single-service form: `up <svc>` acts on the active deployment (the
+ # startup peek skipped the pointer reset). Full-stack-only knobs are
+ # rejected rather than silently ignored.
+ if [[ -n "$one_svc" ]]; then
+ if [[ -n "$SKIP_LIST" ]] || $FRESH || $selector_seen; then
+ tui_err "--skip / --fresh / --worktree / --branch apply to the full-stack \`up\` only" >&2
+ exit 2
+ fi
+ local ec=0
+ cmd_up_one "$one_svc" || ec=$?
+ $JSON_OUT && { emit_status_json >&3 || true; }
+ return $ec
+ fi
+
local n_skip=0
[[ -n "$SKIP_LIST" ]] && n_skip=$(echo "$SKIP_LIST" | tr ',' '\n' | wc -l | tr -d ' ')
local skip_label="none"
@@ -2250,7 +2292,7 @@ cmd_up() {
tui_section "Pre-flight"
tui_ok "no source/lock changes since last build"
tui_ok "all ${#SERVICES[@]} services already running"
- printf "\n ${BOLD}${GREEN}${SYM_OK} nothing to do${RESET} ${DIM}(use \`u --build\` to force a rebuild, or \`<svc>\` to bounce just one)${RESET}\n\n"
+ printf "\n ${BOLD}${GREEN}${SYM_OK} nothing to do${RESET} ${DIM}(use \`up --build\` to force a rebuild, or \`up <svc>\` to bounce just one)${RESET}\n\n"
$JSON_OUT && { emit_status_json >&3 || true; }
return 0
fi
@@ -2288,7 +2330,7 @@ cmd_up() {
refresh_node_deps
else
tui_section "Build"
- tui_skip "build: --no-build (using existing artifacts)"
+ tui_skip "build: --skip-build (using existing artifacts)"
fi
# ▸ Services -- native services only; docker rows were handled by the
@@ -2361,15 +2403,19 @@ cmd_up() {
# Clean services are left alone — no pre-bounce, no restart.
cmd_auto() {
SKIP_LIST=""
+ JSON_OUT=false
while [[ $# -gt 0 ]]; do
case "$1" in
--skip=*) SKIP_LIST="${1#--skip=}" ;;
+ --json) JSON_OUT=true ;;
# Deploy-target selectors are resolved at startup; accept here.
--worktree=*|--branch=*) ;;
*) tui_err "unknown flag: $1" >&2; exit 2 ;;
esac
shift
done
+ # See cmd_up: human progress to stderr, JSON summary on real stdout (fd 3).
+ if $JSON_OUT; then exec 3>&1 1>&2; fi
tui_banner "Texera Local Dev — auto bounce" \
"rebuild + bounce only what changed since last build"
@@ -2408,6 +2454,7 @@ cmd_auto() {
if (( ${#dirty_jvms[@]} == 0 )) && ! $need_yarn && ! $need_bun; then
tui_ok "everything up-to-date — nothing to bounce"
printf "\n"
+ $JSON_OUT && { emit_status_json >&3 || true; }
return 0
fi
@@ -2437,6 +2484,7 @@ cmd_auto() {
phase_clear "$_s"
done
tui_err "sbt dist failed ${DIM}(tail -f $log)${RESET}"
+ $JSON_OUT && { emit_status_json >&3 || true; }
return 1
fi
tui_ok "sbt: dist done"
@@ -2589,22 +2637,46 @@ cmd_auto() {
printf "\n"
printf " ${BOLD}${GREEN}${SYM_OK} auto bounce done${RESET}: %d rebuilt, %d bounced\n\n" \
"$n_rebuilt" "$n_bounced"
- cmd_status
+ if $JSON_OUT; then emit_status_json >&3 || true; else cmd_status; fi
}
cmd_down() {
SKIP_LIST=""
JSON_OUT=false
+ local one_svc=""
while [[ $# -gt 0 ]]; do
case "$1" in
--skip=*) SKIP_LIST="${1#--skip=}" ;;
--json) JSON_OUT=true ;;
- *) tui_err "unknown flag: $1" >&2; exit 2 ;;
+ -*) tui_err "unknown flag: $1" >&2; exit 2 ;;
+ *) if [[ -n "$one_svc" ]]; then
+ tui_err "down takes at most one service (got '$one_svc' and '$1')" >&2; exit 2
+ fi
+ one_svc="$1" ;;
esac
shift
done
# See cmd_up: human progress to stderr, JSON summary on real stdout (fd 3).
if $JSON_OUT; then exec 3>&1 1>&2; fi
+
+ # Single-service form: stop just that one (stop_one handles docker via
+ # `compose stop`, so the shared network/volumes stay up for the others).
+ if [[ -n "$one_svc" ]]; then
+ if [[ -n "$SKIP_LIST" ]]; then
+ tui_err "--skip applies to the full-stack \`down\` only" >&2; exit 2
+ fi
+ if ! amap_has SVC_TYPE "$one_svc"; then
+ tui_err "unknown service: ${BOLD}$one_svc${RESET}"
+ printf " ${DIM}Known:${RESET} ${SERVICES[*]}\n"
+ exit 1
+ fi
+ tui_banner "Texera Local Dev — stopping ${one_svc}" "single service"
+ stop_one "$one_svc"
+ printf "\n"
+ $JSON_OUT && { emit_status_json >&3 || true; }
+ return 0
+ fi
+
tui_banner "Texera Local Dev — stopping stack" "skip=${SKIP_LIST:-none}"
tui_section "Stopping (reverse order)"
local svc=""
@@ -2635,66 +2707,124 @@ cmd_down() {
return 0
}
-cmd_update_one() {
+# Single-service `up`: make one service current and running, honoring the
+# same BUILD knob as the full-stack form:
+# auto (default) — rebuild + bounce only if its source changed since the
+# last build; otherwise just start it if stopped
+# force (--build) — rebuild + bounce unconditionally
+# no (--skip-build) — start with the existing artifacts, never build
+cmd_up_one() {
local svc="$1"
if ! amap_has SVC_TYPE "$svc"; then
tui_err "unknown service: ${BOLD}$svc${RESET}"
printf " ${DIM}Known:${RESET} ${SERVICES[*]}\n"
- exit 1
+ return 1
fi
local type=""
type=$(amap_get SVC_TYPE "$svc")
case "$type" in
docker)
- tui_banner "Restarting ${svc}" "docker compose restart $svc"
- tui_step "$svc: docker compose restart $svc"
- docker compose -p "$DOCKER_PROJECT" restart "$svc" >/dev/null 2>&1 \
- && tui_ok "$svc: restarted" \
- || { tui_err "$svc: restart failed"; exit 1; }
- exit 0
+ # No source to build — --build degrades to a restart, everything
+ # else to an idempotent `compose up -d`.
+ if [[ "$BUILD" == "force" ]]; then
+ tui_banner "Restarting ${svc}" "docker compose restart $svc"
+ tui_step "$svc: docker compose restart $svc"
+ docker compose -p "$DOCKER_PROJECT" restart "$svc" >/dev/null 2>&1 \
+ && tui_ok "$svc: restarted" \
+ || { tui_err "$svc: restart failed"; return 1; }
+ else
+ tui_banner "Starting ${svc}" "docker compose up -d $svc"
+ start_one "$svc" || return 1
+ fi
+ # compose returns before the container is healthy; its state is
+ # the docker poller's job, not a port wait here.
+ return 0
;;
yarn)
- tui_warn "frontend uses ng's watch -- source changes hot-reload automatically."
- printf " ${DIM}For dep changes: kill PID ${RESET}$(svc_running_pid frontend)${DIM}; then bin/local-dev.sh up${RESET}\n"
- exit 0
+ if [[ -n "$(svc_running_pid "$svc")" ]]; then
+ tui_warn "frontend uses ng's watch -- source changes hot-reload automatically."
+ printf " ${DIM}For dep changes: bin/local-dev.sh down frontend && bin/local-dev.sh up frontend${RESET}\n"
+ return 0
+ fi
+ tui_banner "Starting ${svc}" "yarn start (ng serve)"
+ if [[ "$BUILD" != "no" ]] && needs_yarn_install; then
+ tui_section "Deps"
+ local _ylog="$LOG_DIR/yarn-install.log"
+ tui_run_with_spinner "$_ylog" "yarn install ${DIM}(log: $_ylog)${RESET}" \
+ bash -c "cd frontend && yarn install" \
+ || { tui_err "yarn install failed ${DIM}(tail -f $_ylog)${RESET}"; return 1; }
+ fi
+ start_one "$svc" || return 1
;;
bun)
- tui_banner "Updating ${svc}" "bun install + bounce"
- tui_section "Deps"
- ( cd "$(amap_get SVC_CWD "$svc")" && bun install )
- tui_section "Bounce"
- stop_one "$svc"
- start_one "$svc"
- ;;
- jvm)
- local _sbt_proj=""
- _sbt_proj=$(amap_get SVC_SBT "$svc")
- if [[ -n "$_sbt_proj" ]]; then
- tui_banner "Updating ${svc}" "sbt ${_sbt_proj}/dist + bounce"
+ if [[ "$BUILD" == "force" ]]; then
+ tui_banner "Updating ${svc}" "bun install + bounce"
+ tui_section "Deps"
+ ( cd "$(amap_get SVC_CWD "$svc")" && bun install )
+ tui_section "Bounce"
+ stop_one "$svc"
+ start_one "$svc"
else
- tui_banner "Updating ${svc}" "bounce only (shares dist with its sibling)"
+ if [[ -n "$(svc_running_pid "$svc")" ]]; then
+ tui_ok "$svc: already running ${DIM}(bun --watch picks up source changes)${RESET}"
+ return 0
+ fi
+ tui_banner "Starting ${svc}" "bun run dev"
+ if [[ "$BUILD" != "no" ]] && needs_bun_install; then
+ tui_section "Deps"
+ local _blog="$LOG_DIR/bun-install.log"
+ tui_run_with_spinner "$_blog" "bun install ${DIM}(log: $_blog)${RESET}" \
+ bash -c "cd agent-service && bun install" \
+ || { tui_err "bun install failed ${DIM}(tail -f $_blog)${RESET}"; return 1; }
+ fi
+ start_one "$svc" || return 1
fi
- tui_section "Build"
- build_one_jvm "$svc"
- tui_section "Bounce"
- # amber's two siblings share a dist — rebuilding one moves the
- # jar bytes underneath the other. Always bounce them together
- # so neither ends up running stale code (or silently dead).
- local sibling=""
- case "$svc" in
- texera-web) sibling="computing-unit-master" ;;
- computing-unit-master) sibling="texera-web" ;;
+ ;;
+ jvm)
+ local rebuild=false
+ case "$BUILD" in
+ force) rebuild=true ;;
+ auto) svc_src_changed "$svc" && rebuild=true ;;
esac
- local sibling_was_running=false
- if [[ -n "$sibling" ]] && [[ -n "$(svc_running_pid "$sibling")" ]]; then
- sibling_was_running=true
- tui_step "$sibling: stopping (shares amber dist with $svc)"
- stop_one "$sibling"
- fi
- stop_one "$svc"
- start_one "$svc"
- if $sibling_was_running; then
- start_one "$sibling"
+ if $rebuild; then
+ local _sbt_proj=""
+ _sbt_proj=$(amap_get SVC_SBT "$svc")
+ if [[ -n "$_sbt_proj" ]]; then
+ tui_banner "Updating ${svc}" "sbt ${_sbt_proj}/dist + bounce"
+ else
+ tui_banner "Updating ${svc}" "bounce only (shares dist with its sibling)"
+ fi
+ tui_section "Build"
+ # jOOQ codegen connects to postgres at sbt-compile time (#6007).
+ ensure_postgres_for_build
+ build_one_jvm "$svc"
+ tui_section "Bounce"
+ # amber's two siblings share a dist — rebuilding one moves the
+ # jar bytes underneath the other. Always bounce them together
+ # so neither ends up running stale code (or silently dead).
+ local sibling=""
+ case "$svc" in
+ texera-web) sibling="computing-unit-master" ;;
+ computing-unit-master) sibling="texera-web" ;;
+ esac
+ local sibling_was_running=false
+ if [[ -n "$sibling" ]] && [[ -n "$(svc_running_pid "$sibling")" ]]; then
+ sibling_was_running=true
+ tui_step "$sibling: stopping (shares amber dist with $svc)"
+ stop_one "$sibling"
+ fi
+ stop_one "$svc"
+ start_one "$svc"
+ if $sibling_was_running; then
+ start_one "$sibling"
+ fi
+ else
+ if [[ -n "$(svc_running_pid "$svc")" ]]; then
+ tui_ok "$svc: already running and up-to-date ${DIM}(PID $(svc_running_pid "$svc"))${RESET}"
+ return 0
+ fi
+ tui_banner "Starting ${svc}" "existing artifacts (no source change)"
+ start_one "$svc" || return 1
fi
;;
esac
@@ -2706,11 +2836,19 @@ cmd_update_one() {
else
printf " ${RED}${SYM_ERR}${RESET} %-32s ${DIM}:%s${RESET} ${RED}timeout${RESET} ${DIM}(bin/local-dev.sh logs %s)${RESET}\n" \
"$svc" "$_port" "$svc"
- exit 1
+ return 1
fi
printf "\n"
}
+cmd_version() {
+ case "${1:-}" in
+ --json) printf '{"version":"%s","elapsed_seconds":%d}\n' "$TEXERA_VERSION" "$SECONDS" ;;
+ "") printf "%s\n" "$TEXERA_VERSION" ;;
+ *) tui_err "unknown flag: $1" >&2; exit 2 ;;
+ esac
+}
+
cmd_logs() {
local svc="${1:?usage: bin/local-dev.sh logs <service>}"
if ! amap_has SVC_TYPE "$svc"; then
@@ -2781,11 +2919,10 @@ tui_render_dashboard() {
printf "\n\n"
printf " ${DIM}Commands:${RESET} "
printf "${BOLD}r${RESET}efresh${DIM} (or just ↩)${RESET} · "
- printf "${BOLD}u${RESET}p · ${BOLD}d${RESET}own · "
+ printf "${BOLD}u${RESET}p ${DIM}[<svc>]${RESET} · "
+ printf "${BOLD}d${RESET}own ${DIM}[<svc>]${RESET} · "
printf "${BOLD}b${RESET}uild · "
- printf "${BOLD}<svc>${RESET}${DIM}=rebuild+bounce${RESET} · "
printf "${BOLD}l${RESET}ogs ${DIM}<svc>${RESET} · "
- printf "${BOLD}s${RESET}top ${DIM}<svc>${RESET} · "
printf "${BOLD}q${RESET}uit\n\n"
}
@@ -2910,11 +3047,12 @@ case "${1:-}" in
up) shift; cmd_up "$@" ;;
auto) shift; cmd_auto "$@" ;;
down) shift; cmd_down "$@" ;;
- start) shift; start_one "${1:?need service name}" ;;
- stop) shift; stop_one "${1:?need service name}" ;;
+ start|stop) tui_err "\`$1\` was removed — use \`bin/local-dev.sh up <service>\` / \`down <service>\`" >&2
+ exit 2 ;;
logs) shift; cmd_logs "${1:-}" ;;
w|watch) shift; cmd_watch "${1:-2}" ;;
- version) printf "%s\n" "$TEXERA_VERSION" ;;
- -h|--help) sed -n '18,92p' "$0" ;;
- *) cmd_update_one "$1" ;;
+ version) shift; cmd_version "$@" ;;
+ -h|--help) sed -n '18,/^# Logs and pid book-keeping/p' "$0" ;;
+ *) tui_err "unknown subcommand: ${BOLD}$1${RESET} ${DIM}(for one service: up/down $1)${RESET}" >&2
+ exit 2 ;;
esac
diff --git a/bin/local-dev/tests/test_local_dev_sh.sh b/bin/local-dev/tests/test_local_dev_sh.sh
index 486a982ee1..63320386a7 100755
--- a/bin/local-dev/tests/test_local_dev_sh.sh
+++ b/bin/local-dev/tests/test_local_dev_sh.sh
@@ -94,26 +94,69 @@ else
_fail "--help didn't show usage" "$(echo "$help_out" | head -3)"
fi
-# 5) An unknown service name routes through cmd_update_one and exits
-# non-zero rather than silently doing nothing.
+# 5) An unknown subcommand exits non-zero with a clear error rather than
+# silently doing nothing, and the single-service form rejects unknown
+# service names the same way.
out=$("$SCRIPT" definitely-not-a-real-service 2>&1)
rc=$?
-if (( rc != 0 )) && [[ "$out" == *"unknown service"* || "$out" == *"Unknown service"* ]]; then
- _pass "unknown service exits non-zero with clear error"
+if (( rc != 0 )) && [[ "$out" == *"unknown subcommand"* ]]; then
+ _pass "unknown subcommand exits non-zero with clear error"
else
- _fail "unknown service didn't error properly" "rc=$rc out=$out"
+ _fail "unknown subcommand didn't error properly" "rc=$rc out=$out"
fi
-
-# 6) `start` with no service name fails immediately (zsh parameter expansion
-# `${1:?...}` exits with the message).
-out=$("$SCRIPT" start 2>&1)
+# Isolated state dir: a full-stack `up` (re)persists the deploy pointer during
+# startup even when the flag parse later fails, so never point these at the
+# real deployment's state.
+_up_state=$(mktemp -d 2>/dev/null || mktemp -d -t ldup)
+out=$(TEXERA_LOCAL_DEV_DIR="$_up_state" "$SCRIPT" up definitely-not-a-real-service 2>&1)
rc=$?
-if (( rc != 0 )) && [[ "$out" == *"need service name"* ]]; then
- _pass "start without arg refuses cleanly"
+if (( rc != 0 )) && [[ "$out" == *"unknown service"* ]]; then
+ _pass "up <unknown-service> exits non-zero with clear error"
else
- _fail "start without arg should refuse" "rc=$rc out=$out"
+ _fail "up <unknown-service> didn't error properly" "rc=$rc out=$out"
fi
+# 6) `start`/`stop` were replaced by `up <service>` / `down <service>` — they
+# must refuse with a pointer to the new spelling, not silently no-op.
+for verb in start stop; do
+ out=$("$SCRIPT" "$verb" texera-web 2>&1)
+ rc=$?
+ if (( rc != 0 )) && [[ "$out" == *"up <service>"* ]]; then
+ _pass "$verb points to up/down <service>"
+ else
+ _fail "$verb should refuse with the new spelling" "rc=$rc out=$out"
+ fi
+done
+
+# 6b) Flag hygiene on the new surface: --no-build is gone (renamed), the
+# full-stack-only knobs are rejected in the single-service form, and at
+# most one service is accepted.
+out=$(TEXERA_LOCAL_DEV_DIR="$_up_state" "$SCRIPT" up --no-build 2>&1); rc=$?
+if (( rc == 2 )) && [[ "$out" == *"--skip-build"* ]]; then
+ _pass "up --no-build refuses and names --skip-build"
+else
+ _fail "up --no-build not rejected with rename hint" "rc=$rc out=$out"
+fi
+out=$(TEXERA_LOCAL_DEV_DIR="$_up_state" "$SCRIPT" up texera-web --fresh 2>&1); rc=$?
+if (( rc == 2 )) && [[ "$out" == *"full-stack"* ]]; then
+ _pass "up <svc> --fresh refuses (full-stack only)"
+else
+ _fail "up <svc> --fresh not rejected" "rc=$rc out=$out"
+fi
+out=$(TEXERA_LOCAL_DEV_DIR="$_up_state" "$SCRIPT" up texera-web frontend 2>&1); rc=$?
+if (( rc == 2 )) && [[ "$out" == *"at most one service"* ]]; then
+ _pass "up refuses two positional services"
+else
+ _fail "up accepted two services" "rc=$rc out=$out"
+fi
+out=$(TEXERA_LOCAL_DEV_DIR="$_up_state" "$SCRIPT" down texera-web --skip=frontend 2>&1); rc=$?
+if (( rc == 2 )) && [[ "$out" == *"full-stack"* ]]; then
+ _pass "down <svc> --skip refuses (full-stack only)"
+else
+ _fail "down <svc> --skip not rejected" "rc=$rc out=$out"
+fi
+rm -rf "$_up_state"
+
# 7) No-arg invocation must be non-interactive (= `status`). Previously the
# default launched the TUI, which made the script unsafe to drop into
# cron jobs or CI smoke tests. Anything that prints the banner without
@@ -246,6 +289,7 @@ assert 0 <= d["running"] <= d["total"], "running out of range"
names = {s["service"] for s in d["services"]}
need = {"texera-web", "frontend", "postgres"}
assert need <= names, f"missing services: {need - names}"
+assert isinstance(d["elapsed_seconds"], int) and d["elapsed_seconds"] >= 0, "elapsed_seconds missing/bad"
for s in d["services"]:
assert isinstance(s["port"], int), "port not int"
assert s["type"] in {"jvm", "docker", "yarn", "bun"}, "bad service type"
@@ -301,10 +345,10 @@ else
_fail "tui_spinner missing non-TTY heartbeat loop"
fi
-# 17) `up` and `down` accept --json (route the human stream to stderr, emit the
-# JSON summary on stdout). Structural guard — invoking them for real would
-# build/stop the stack, out of scope here.
-for fn in cmd_up cmd_down; do
+# 17) `up`, `down`, and `auto` accept --json (route the human stream to stderr,
+# emit the JSON summary on stdout). Structural guard — invoking them for
+# real would build/stop the stack, out of scope here.
+for fn in cmd_up cmd_down cmd_auto; do
body=$(awk -v fn="$fn" '$0 ~ "^" fn "\\(\\)" {f=1} f{print} f&&/^}/{exit}' \
"$REPO_ROOT/bin/local-dev/main.sh")
if [[ "$body" == *"--json"* && "$body" == *"emit_status_json"* ]]; then
@@ -314,6 +358,35 @@ for fn in cmd_up cmd_down; do
fi
done
+# 17b) `version --json` is machine-readable and carries elapsed_seconds — the
+# runtime field is part of every --json payload, not just status.
+if command -v python3 >/dev/null 2>&1; then
+ out=$("$SCRIPT" version --json 2>/dev/null)
+ if printf '%s' "$out" | python3 -c '
+import sys, json
+d = json.load(sys.stdin)
+assert d["version"], "version empty"
+assert isinstance(d["elapsed_seconds"], int) and d["elapsed_seconds"] >= 0
+' 2>/dev/null; then
+ _pass "version --json emits version + elapsed_seconds"
+ else
+ _fail "version --json invalid" "out=$out"
+ fi
+else
+ _pass "skip: python3 not on PATH (version --json check)"
+fi
+
+# 17c) The single-service `up <svc>` form must follow the active deployment
+# rather than resetting the persisted worktree pointer (only a full-stack
+# `up` re-decides the target). Structural: the startup peek skips the
+# reset/persist block when a positional service arg is present.
+peek=$(sed -n '/self tree vs deploy source/,/^REPO_ROOT=/p' "$REPO_ROOT/bin/local-dev/main.sh")
+if [[ "$peek" == *"_has_svc_arg"* ]]; then
+ _pass "up <svc> leaves the deploy-source pointer alone"
+else
+ _fail "startup peek doesn't guard the pointer reset on up <svc>"
+fi
+
# 18) Deploy-source: `--help` documents the worktree selectors.
help_out=$("$SCRIPT" --help 2>&1)
if [[ "$help_out" == *"--worktree="* && "$help_out" == *"--branch="* ]]; then
diff --git a/bin/local-dev/tui.py b/bin/local-dev/tui.py
index 3170f60077..305244307e 100644
--- a/bin/local-dev/tui.py
+++ b/bin/local-dev/tui.py
@@ -1343,7 +1343,7 @@ class LocalDevApp(App):
"Commands:",
" r refresh state now",
" u build + start every service",
- " u <svc> start one service (no rebuild)",
+ " u <svc> bring one service up (rebuilds only if its source changed)",
" u --branch=NAME deploy that branch's worktree, then build+start",
" u --worktree=PATH deploy that worktree, then build+start",
" (plain `u` deploys this checkout; works on `a`/`auto` too —",
@@ -1354,9 +1354,9 @@ class LocalDevApp(App):
" d <svc> stop one service",
" b force incremental sbt + node deps",
" a / auto scan for dirty services and rebuild+bounce only those",
- " <svc> rebuild that service and bounce it",
+ " <svc> same as `u <svc>` but forces the rebuild + bounce",
" l <svc> tail that service's log (Ctrl-C returns)",
- " s <svc> stop one service",
+ " s <svc> stop one service (alias for `d <svc>`)",
" clear clear the log pane",
" log toggle log pane visibility",
" banner toggle banner (collapse the wordmark to save rows; Ctrl-B also works)",
@@ -1408,7 +1408,7 @@ class LocalDevApp(App):
if svc not in SERVICES_BY_NAME:
self._log_err(f"unknown service: {svc}")
return
- argv = ["start", svc]
+ argv = ["up", svc]
else:
argv = ["up"]
elif verb in ("d", "down"):
@@ -1416,14 +1416,14 @@ class LocalDevApp(App):
if arg not in SERVICES_BY_NAME:
self._log_err(f"unknown service: {arg}")
return
- argv = ["stop", arg]
+ argv = ["down", arg]
else:
argv = ["down"]
elif verb in ("s", "stop"):
if not arg or arg not in SERVICES_BY_NAME:
self._log_err("usage: s <service>")
return
- argv = ["stop", arg]
+ argv = ["down", arg]
elif verb in ("b", "build"):
# Force an incremental build; the shell handles the "is this
# really needed" decision itself (it pre-bounces JVMs etc.).
@@ -1441,15 +1441,14 @@ class LocalDevApp(App):
elif verb in SERVICES_BY_NAME:
svc_obj = SERVICES_BY_NAME[verb]
if svc_obj.type in WATCH_TYPES:
- # ng serve / bun --watch rebuild on save automatically. The
- # shell's cmd_update_one already refuses this, but we get a
- # nicer message by intercepting here.
+ # ng serve / bun --watch rebuild on save automatically —
+ # forcing a rebuild is never what the user wants here.
self._log_msg(
f"{verb} runs in watch mode (↻) — source edits auto-reload. "
- f"If you really need to bounce it, run `s {verb}` then `u {verb}`."
+ f"If you really need to bounce it, run `d {verb}` then `u {verb}`."
)
return
- argv = [verb]
+ argv = ["up", verb, "--build"]
else:
self._log_err(f"unknown: {verb} (type 'h' for help)")
return
@@ -1458,7 +1457,9 @@ class LocalDevApp(App):
return
# A full `up`/`auto` re-decides (and re-persists) the deploy target —
# including a plain `up`, which resets to this checkout — so re-point the
- # banner afterward. `u <svc>` maps to `start` and leaves the target be.
+ # banner afterward. A single-service `up <svc>` / `down <svc>` follows
+ # the active target without re-deciding it; the resync is a harmless
+ # re-read in that case.
resync = argv[0] in ("up", "auto")
self._spawn_action(verb if not arg else f"{verb} {arg}", argv, resync=resync)