mirror of
https://github.com/multica-ai/multica.git
synced 2026-09-28 05:13:45 +08:00
Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
1526 lines
58 KiB
Bash
Executable File
1526 lines
58 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Local development environments as named, listable, deletable objects.
|
|
#
|
|
# make up # start this checkout's environment (api + web)
|
|
# make up C=api,web,daemon # pick the components
|
|
# make status # what is running, and is it mine
|
|
# make list # every environment on this machine
|
|
# make down # stop the processes, keep the data
|
|
# make destroy # stop, then delete database + profile + slot
|
|
#
|
|
# Three rules the old flow got wrong, and why they are here:
|
|
#
|
|
# 1. Ports, database names and CLI profiles are ALLOCATED under a lock and
|
|
# recorded in a registry, not recomputed from cksum($PWD) by every caller.
|
|
# Deriving an identity in a 1000-slot namespace with no coordination
|
|
# collides, and a collision here is silent: the loser's process fails to
|
|
# bind while the winner keeps answering.
|
|
# 2. Nothing prints a checkmark for a resource it has not reached the way the
|
|
# application reaches it. The database is verified through DATABASE_URL,
|
|
# never through `docker exec` — when a native PostgreSQL owns 5432 the
|
|
# container never binds the host port, so a docker-exec create lands in a
|
|
# server the backend never talks to.
|
|
# 3. Creating an environment writes down how to destroy it. Without that
|
|
# record there is no destroy, only archaeology; the databases, ports and
|
|
# daemons leak, and nothing on the machine can even list them.
|
|
set -euo pipefail
|
|
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
|
|
DEV_HOME="${MULTICA_DEV_HOME:-$HOME/.multica/dev}"
|
|
ENVS_DIR="$DEV_HOME/envs"
|
|
LOCK_DIR="$DEV_HOME/lock.d"
|
|
DEV_WORKSPACES_PARENT="${MULTICA_DEV_WORKSPACES_PARENT:-$HOME}"
|
|
DEV_DESKTOP_APP_DATA="${MULTICA_DEV_DESKTOP_APP_DATA:-}"
|
|
DEV_PROFILES_HOME="${MULTICA_DEV_PROFILES_HOME:-$HOME/.multica/profiles}"
|
|
|
|
DEV_EMAIL="${MULTICA_DEV_EMAIL:-dev@localhost}"
|
|
DEV_CODE_DEFAULT=888888
|
|
WORKSPACE_NAME="${MULTICA_DEV_WORKSPACE_NAME:-Dev}"
|
|
WORKSPACE_SLUG="${MULTICA_DEV_WORKSPACE_SLUG:-dev}"
|
|
|
|
ALL_COMPONENTS="api web daemon desktop"
|
|
DEFAULT_COMPONENTS="api web"
|
|
|
|
# An agent runs with TMPDIR=/tmp/multica-task-<id>, deleted when the run ends.
|
|
# Anything the Go toolchain builds there goes with it, so a binary started from
|
|
# such a build stops being re-executable the moment its creator finishes.
|
|
DEV_TMPDIR="${MULTICA_DEV_TMPDIR:-$HOME/.multica/dev-tmp}"
|
|
|
|
# The agent runtime exports these pointing at PRODUCTION, and MULTICA_SERVER_URL
|
|
# silently outranks server_url in a saved profile config. Every long-lived child
|
|
# is launched without them, so a local daemon cannot authenticate its local
|
|
# token against the production API — which fails as a bare 401 and reads like a
|
|
# product bug. PATH is never stripped: the daemon resolves agent CLI paths by
|
|
# forking the login shell.
|
|
CLEAN_ENV=(env
|
|
-u MULTICA_SERVER_URL -u MULTICA_TOKEN -u MULTICA_WORKSPACE_ID
|
|
-u MULTICA_DAEMON_PORT -u MULTICA_AGENT_ID -u MULTICA_AGENT_NAME
|
|
-u MULTICA_TASK_ID -u MULTICA_TASK_SLOT
|
|
-u MULTICA_TASK_CONFIG_ROOT -u MULTICA_TASK_WORKSPACES_ROOT
|
|
-u MULTICA_WORKSPACES_ROOT)
|
|
|
|
# ---------------------------------------------------------------- output ----
|
|
|
|
if [ -t 1 ]; then
|
|
C_BOLD=$'\033[1m'; C_GREEN=$'\033[32m'; C_RED=$'\033[31m'; C_DIM=$'\033[2m'; C_OFF=$'\033[0m'
|
|
else
|
|
C_BOLD=""; C_GREEN=""; C_RED=""; C_DIM=""; C_OFF=""
|
|
fi
|
|
|
|
step() { printf '\n%s==> %s%s\n' "$C_BOLD" "$1" "$C_OFF"; }
|
|
info() { printf ' %s\n' "$1"; }
|
|
ok() { printf ' %s✓%s %s\n' "$C_GREEN" "$C_OFF" "$1"; }
|
|
warn() { printf ' %s!%s %s\n' "$C_RED" "$C_OFF" "$1"; }
|
|
die() { printf '\n%s✗ %s%s\n' "$C_RED" "$1" "$C_OFF" >&2; exit 1; }
|
|
|
|
json_escape() {
|
|
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g'
|
|
}
|
|
|
|
now_iso() { date -u '+%Y-%m-%dT%H:%M:%SZ'; }
|
|
now_epoch() { date -u '+%s'; }
|
|
|
|
expires_at_after_hours() {
|
|
node -e '
|
|
const hours = Number(process.argv[1]);
|
|
process.stdout.write(new Date(Date.now() + hours * 3600_000).toISOString().replace(/\.\d{3}Z$/, "Z"));
|
|
' "$1"
|
|
}
|
|
|
|
# ----------------------------------------------------------------- locking ---
|
|
|
|
# flock is not on a stock macOS, so the lock is an atomic mkdir. The holder's
|
|
# pid is recorded so a lock left behind by a killed process is recoverable
|
|
# instead of wedging every later command.
|
|
acquire_lock() {
|
|
local waited=0
|
|
mkdir -p "$DEV_HOME"
|
|
while ! mkdir "$LOCK_DIR" 2>/dev/null; do
|
|
local holder=""
|
|
holder="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)"
|
|
if [ -n "$holder" ] && ! kill -0 "$holder" 2>/dev/null; then
|
|
rm -rf "$LOCK_DIR"
|
|
continue
|
|
fi
|
|
waited=$((waited + 1))
|
|
[ "$waited" -lt 100 ] || die "Timed out waiting for the allocation lock at $LOCK_DIR. If nothing else is running, remove it."
|
|
sleep 0.1
|
|
done
|
|
printf '%s\n' "$$" > "$LOCK_DIR/pid"
|
|
}
|
|
|
|
release_lock() { rm -rf "$LOCK_DIR"; }
|
|
|
|
# ---------------------------------------------------------------- registry ---
|
|
|
|
env_dir() { printf '%s/%s' "$ENVS_DIR" "$1"; }
|
|
manifest_of() { printf '%s/%s/manifest.env' "$ENVS_DIR" "$1"; }
|
|
|
|
valid_env_name() {
|
|
case "$1" in
|
|
""|*[!a-z0-9_-]*|-*|_*) return 1 ;;
|
|
*) [ "${#1}" -le 128 ] ;;
|
|
esac
|
|
}
|
|
|
|
require_env_name() {
|
|
valid_env_name "$1" || die "Invalid environment name '$1'. Use 1-128 lowercase letters, numbers, '-' or '_', starting with a letter or number."
|
|
}
|
|
|
|
require_ttl() {
|
|
case "$1" in
|
|
""|0|*[!0-9]*) die "TTL must be a positive integer number of hours." ;;
|
|
esac
|
|
}
|
|
|
|
list_env_names() {
|
|
[ -d "$ENVS_DIR" ] || return 0
|
|
local path
|
|
for path in "$ENVS_DIR"/*/manifest.env; do
|
|
[ -f "$path" ] || continue
|
|
basename "$(dirname "$path")"
|
|
done
|
|
return 0
|
|
}
|
|
|
|
# Loads a manifest into NAME/DIR/BACKEND_PORT/... in the caller's scope.
|
|
load_manifest() {
|
|
local file
|
|
require_env_name "$1"
|
|
file="$(manifest_of "$1")"
|
|
[ -f "$file" ] || return 1
|
|
# shellcheck disable=SC1090
|
|
. "$file"
|
|
[ "$NAME" = "$1" ] || die "Manifest $file declares NAME=$NAME; expected $1."
|
|
}
|
|
|
|
# Prints nothing (and succeeds) for a missing manifest or key: callers compare
|
|
# the value, and a non-zero return from a command substitution is fatal under
|
|
# `set -e` on bash 3.2.
|
|
manifest_field() {
|
|
local file key=$2
|
|
require_env_name "$1"
|
|
file="$(manifest_of "$1")"
|
|
[ -f "$file" ] || return 0
|
|
(
|
|
# shellcheck disable=SC1090
|
|
. "$file"
|
|
case "$key" in
|
|
DIR) printf '%s' "$DIR" ;;
|
|
OFFSET) printf '%s' "$OFFSET" ;;
|
|
*) return 1 ;;
|
|
esac
|
|
)
|
|
}
|
|
|
|
write_manifest_value() {
|
|
printf '%s=' "$1"
|
|
printf '%q' "$2"
|
|
printf '\n'
|
|
}
|
|
|
|
env_name_for_dir() {
|
|
local name
|
|
while read -r name; do
|
|
[ -n "$name" ] || continue
|
|
if [ "$(manifest_field "$name" DIR)" = "$1" ]; then
|
|
printf '%s' "$name"
|
|
return 0
|
|
fi
|
|
done <<EOF
|
|
$(list_env_names)
|
|
EOF
|
|
return 1
|
|
}
|
|
|
|
# ------------------------------------------------------------------- ports ---
|
|
|
|
# -sTCP:LISTEN matters: an unfiltered lsof also returns CLIENTS of the port, and
|
|
# the daemon holds a long-lived connection to the backend. Killing what the
|
|
# unfiltered lookup returns takes the daemon down with the server (#6573).
|
|
#
|
|
# The trailing `|| true` is load-bearing on macOS's bash 3.2: `x="$(fn)"` inside
|
|
# a function aborts the script under `set -e` when fn's last command fails, and
|
|
# "no process is listening" is the normal answer here, not an error.
|
|
port_listener_pid() {
|
|
lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null | head -1 || true
|
|
}
|
|
|
|
port_free() { [ -z "$(port_listener_pid "$1")" ]; }
|
|
|
|
describe_port_owner() {
|
|
local pid
|
|
pid="$(port_listener_pid "$1")"
|
|
[ -n "$pid" ] || { printf 'free'; return; }
|
|
printf 'pid %s (%s), up %s' "$pid" \
|
|
"$(ps -p "$pid" -o comm= 2>/dev/null | sed 's/^ *//' || echo unknown)" \
|
|
"$(ps -p "$pid" -o etime= 2>/dev/null | sed 's/^ *//' || echo unknown)"
|
|
}
|
|
|
|
# -------------------------------------------------------------- allocation ---
|
|
|
|
slugify() {
|
|
local slug
|
|
slug="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g; s/__*/_/g; s/^_//; s/_$//')"
|
|
printf '%s' "${slug:-env}"
|
|
}
|
|
|
|
path_offset() {
|
|
local sum
|
|
sum="$(printf '%s' "$1" | cksum | awk '{print $1}')"
|
|
printf '%s' $((sum % 1000))
|
|
}
|
|
|
|
renderer_port_for_offset() {
|
|
local port=$((5174 + $1))
|
|
[ "$port" -ne 6000 ] || port=6174
|
|
printf '%s' "$port"
|
|
}
|
|
|
|
desktop_app_data_root() {
|
|
if [ -n "$DEV_DESKTOP_APP_DATA" ]; then
|
|
printf '%s' "$DEV_DESKTOP_APP_DATA"
|
|
return 0
|
|
fi
|
|
case "$(uname -s)" in
|
|
Darwin) printf '%s/Library/Application Support' "$HOME" ;;
|
|
MINGW*|MSYS*|CYGWIN*) printf '%s' "${APPDATA:-$HOME/AppData/Roaming}" ;;
|
|
*) printf '%s' "${XDG_CONFIG_HOME:-$HOME/.config}" ;;
|
|
esac
|
|
}
|
|
|
|
desktop_user_data_dir() {
|
|
printf '%s/Multica Canary %s' "$(desktop_app_data_root)" "$1"
|
|
}
|
|
|
|
offset_registered() {
|
|
local name
|
|
while read -r name; do
|
|
[ -n "$name" ] || continue
|
|
[ "$name" = "${2:-}" ] && continue
|
|
if [ "$(manifest_field "$name" OFFSET)" = "$1" ]; then
|
|
return 0
|
|
fi
|
|
done <<EOF
|
|
$(list_env_names)
|
|
EOF
|
|
return 1
|
|
}
|
|
|
|
# Probes for a usable slot starting at the path hash, so the common case keeps
|
|
# the deterministic number a checkout has always had, and only a real conflict
|
|
# moves it. A slot is usable when the registry does not hold it AND both ports
|
|
# are actually free — the registry alone cannot see a process started before
|
|
# this tooling existed.
|
|
allocate_offset() {
|
|
local dir=$1 self=${2:-} start i offset backend frontend renderer
|
|
start="$(path_offset "$dir")"
|
|
for i in $(seq 0 999); do
|
|
offset=$(((start + i) % 1000))
|
|
backend=$((18080 + offset))
|
|
frontend=$((13000 + offset))
|
|
renderer="$(renderer_port_for_offset "$offset")"
|
|
offset_registered "$offset" "$self" && continue
|
|
port_free "$backend" || continue
|
|
port_free "$frontend" || continue
|
|
port_free "$renderer" || continue
|
|
printf '%s' "$offset"
|
|
return 0
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# ---------------------------------------------------------------- env file ---
|
|
|
|
detect_env_file() {
|
|
if [ -f "$REPO_ROOT/.env" ]; then
|
|
printf '.env'
|
|
elif [ -f "$REPO_ROOT/.env.worktree" ]; then
|
|
printf '.env.worktree'
|
|
elif [ -f "$REPO_ROOT/.git" ] && [ ! -d "$REPO_ROOT/.git" ]; then
|
|
printf '.env.worktree'
|
|
else
|
|
printf '.env'
|
|
fi
|
|
}
|
|
|
|
load_env_file() {
|
|
local root="${2:-$REPO_ROOT}"
|
|
set -a
|
|
# shellcheck disable=SC1090
|
|
. "$root/$1"
|
|
set +a
|
|
# shellcheck disable=SC1091
|
|
. "$root/scripts/local-env.sh"
|
|
}
|
|
|
|
# The verification code has to be in the file BEFORE the backend starts: the
|
|
# handler reads the variable at request time, but the process loads the file
|
|
# once. Setting it here removes the start → edit → restart detour, and is what
|
|
# makes `up` able to log itself in without a human reading a log for a code.
|
|
ensure_dev_code() {
|
|
local file="$REPO_ROOT/$1" tmp
|
|
if grep -qE '^MULTICA_DEV_VERIFICATION_CODE=[0-9]{6}$' "$file"; then
|
|
return 0
|
|
fi
|
|
if grep -q '^MULTICA_DEV_VERIFICATION_CODE=' "$file"; then
|
|
tmp="$(mktemp)"
|
|
sed "s/^MULTICA_DEV_VERIFICATION_CODE=.*/MULTICA_DEV_VERIFICATION_CODE=$DEV_CODE_DEFAULT/" "$file" > "$tmp"
|
|
mv "$tmp" "$file"
|
|
else
|
|
printf '\nMULTICA_DEV_VERIFICATION_CODE=%s\n' "$DEV_CODE_DEFAULT" >> "$file"
|
|
fi
|
|
info "Set MULTICA_DEV_VERIFICATION_CODE=$DEV_CODE_DEFAULT in $1 (ignored when APP_ENV=production)."
|
|
}
|
|
|
|
rewrite_env_ports() {
|
|
local file="$REPO_ROOT/$1" offset=$2 backend=$3 frontend=$4 db=$5 tmp database_url escaped_database_url
|
|
database_url="$(database_url_with_name "${DATABASE_URL:-}" "$db")" \
|
|
|| die "DATABASE_URL is not a valid PostgreSQL URL: ${DATABASE_URL:-<unset>}"
|
|
escaped_database_url="$(printf '%s' "$database_url" | sed 's/[\\&|]/\\&/g')"
|
|
tmp="$(mktemp)"
|
|
sed \
|
|
-e "s|^PORT=.*|PORT=${backend}|" \
|
|
-e "s|^FRONTEND_PORT=.*|FRONTEND_PORT=${frontend}|" \
|
|
-e "s|^FRONTEND_ORIGIN=.*|FRONTEND_ORIGIN=http://localhost:${frontend}|" \
|
|
-e "s|^POSTGRES_DB=.*|POSTGRES_DB=${db}|" \
|
|
-e "s|^DATABASE_URL=.*|DATABASE_URL=${escaped_database_url}|" \
|
|
-e "s|^MULTICA_SERVER_URL=.*|MULTICA_SERVER_URL=ws://localhost:${backend}/ws|" \
|
|
-e "s|^MULTICA_PUBLIC_URL=.*|MULTICA_PUBLIC_URL=http://localhost:${backend}|" \
|
|
-e "s|^MULTICA_APP_URL=.*|MULTICA_APP_URL=http://localhost:${frontend}|" \
|
|
-e "s|^NEXT_PUBLIC_API_URL=.*|NEXT_PUBLIC_API_URL=http://localhost:${backend}|" \
|
|
-e "s|^NEXT_PUBLIC_WS_URL=.*|NEXT_PUBLIC_WS_URL=ws://localhost:${backend}/ws|" \
|
|
"$file" > "$tmp"
|
|
mv "$tmp" "$file"
|
|
}
|
|
|
|
# ---------------------------------------------------------------- database ---
|
|
|
|
admin_database_url() {
|
|
node -e '
|
|
const url = new URL(process.argv[1]);
|
|
url.pathname = "/postgres";
|
|
process.stdout.write(url.toString());
|
|
' "$1" 2>/dev/null || true
|
|
}
|
|
|
|
database_url_with_name() {
|
|
node -e '
|
|
const url = new URL(process.argv[1]);
|
|
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") process.exit(1);
|
|
url.pathname = "/" + process.argv[2];
|
|
process.stdout.write(url.toString());
|
|
' "$1" "$2" 2>/dev/null
|
|
}
|
|
|
|
# Diagnoses the failure mode this whole script exists to make impossible:
|
|
# something other than the container owns 5432, so the container never bound
|
|
# the host port and a docker-exec create landed in the wrong server.
|
|
diagnose_database() {
|
|
local owner
|
|
owner="$(lsof -nP -iTCP:"${POSTGRES_PORT:-5432}" -sTCP:LISTEN 2>/dev/null | awk 'NR==2 {print $1" (pid "$2", user "$3")"}')"
|
|
printf '\n'
|
|
warn "The database the tooling created is not the one the application reaches."
|
|
info "Port ${POSTGRES_PORT:-5432} is served by: ${owner:-nothing}"
|
|
info "DATABASE_URL: ${DATABASE_URL}"
|
|
info "If that is a native PostgreSQL, the Docker container never bound the host port."
|
|
info "Either stop it (brew services stop postgresql@17) or point DATABASE_URL at it."
|
|
}
|
|
|
|
ensure_database() {
|
|
local admin_url=""
|
|
if command -v psql >/dev/null 2>&1 && [ -n "${DATABASE_URL:-}" ]; then
|
|
admin_url="$(admin_database_url "$DATABASE_URL")"
|
|
fi
|
|
|
|
# Preferred path: create through the same connection string the application
|
|
# uses, so "created" and "reachable" cannot describe two different servers.
|
|
if [ -n "$admin_url" ] && PGCONNECT_TIMEOUT=3 psql "$admin_url" -tAc 'SELECT 1' >/dev/null 2>&1; then
|
|
if ! PGCONNECT_TIMEOUT=3 psql "$admin_url" -tAc "SELECT 1 FROM pg_database WHERE datname='${POSTGRES_DB}'" | grep -q 1; then
|
|
psql "$admin_url" -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"${POSTGRES_DB}\"" >/dev/null
|
|
info "Created database ${POSTGRES_DB} through DATABASE_URL."
|
|
fi
|
|
else
|
|
info "Nothing is answering on ${POSTGRES_PORT:-5432} yet; starting the shared container."
|
|
bash "$REPO_ROOT/scripts/ensure-postgres.sh" "$ENV_FILE" | sed 's/^/ /'
|
|
fi
|
|
}
|
|
|
|
migrate_database() {
|
|
# `migrate up` connects with DATABASE_URL and pings before doing anything, so
|
|
# a successful run is the proof that the application can reach the database
|
|
# this script just prepared. That is why nothing above prints a checkmark.
|
|
if ! (cd "$REPO_ROOT/server" && go run ./cmd/migrate up) > "$LOG_DIR/migrate.log" 2>&1; then
|
|
tail -5 "$LOG_DIR/migrate.log" | sed 's/^/ /' >&2 || true
|
|
if grep -q '3D000\|does not exist' "$LOG_DIR/migrate.log"; then
|
|
diagnose_database
|
|
fi
|
|
die "Migrations failed. Full log: $LOG_DIR/migrate.log"
|
|
fi
|
|
}
|
|
|
|
# -------------------------------------------------------------- components ---
|
|
|
|
component_selected() {
|
|
case " $COMPONENTS " in *" $1 "*) return 0 ;; *) return 1 ;; esac
|
|
}
|
|
|
|
pid_file() { printf '%s/%s.pid' "$STATE_DIR" "$1"; }
|
|
listener_pid_file() { printf '%s/%s.listener.pid' "$STATE_DIR" "$1"; }
|
|
log_file() { printf '%s/%s.log' "$LOG_DIR" "$1"; }
|
|
|
|
component_pid() {
|
|
local file
|
|
file="$(pid_file "$1")"
|
|
[ -f "$file" ] || return 1
|
|
local pid
|
|
pid="$(cat "$file")"
|
|
kill -0 "$pid" 2>/dev/null || return 1
|
|
printf '%s' "$pid"
|
|
}
|
|
|
|
# set -m puts the launcher in its own process group, so stopping can signal the
|
|
# whole tree (make → go run → server) with one kill, and the child's own
|
|
# `trap 'kill 0'` can never reach back into this shell.
|
|
launch_detached() {
|
|
local name=$1
|
|
shift
|
|
(
|
|
set -m
|
|
nohup "${CLEAN_ENV[@]}" "$@" > "$(log_file "$name")" 2>&1 < /dev/null &
|
|
printf '%s\n' "$!" > "$(pid_file "$name")"
|
|
)
|
|
}
|
|
|
|
health_json() { curl -sf --max-time 3 "http://localhost:${BACKEND_PORT}/health" 2>/dev/null; }
|
|
|
|
json_field() {
|
|
node -e '
|
|
let payload;
|
|
try { payload = JSON.parse(process.argv[1]); } catch { process.exit(1); }
|
|
const value = process.argv[2].split(".").reduce((acc, key) => (acc == null ? acc : acc[key]), payload);
|
|
if (value === undefined || value === null || value === "") process.exit(1);
|
|
process.stdout.write(String(value));
|
|
' "$1" "$2" 2>/dev/null
|
|
}
|
|
|
|
api_started_after() {
|
|
local started_at epoch
|
|
started_at="$(json_field "$1" started_at || true)"
|
|
[ -n "$started_at" ] || return 1
|
|
epoch="$(node -e 'process.stdout.write(String(Math.floor(Date.parse(process.argv[1]) / 1000)))' "$started_at" 2>/dev/null || echo 0)"
|
|
[ "$epoch" -ge $(($2 - 5)) ]
|
|
}
|
|
|
|
checkout_commit() {
|
|
git -C "${DIR:-$REPO_ROOT}" rev-parse --short HEAD 2>/dev/null || printf 'unknown'
|
|
}
|
|
|
|
process_group_id() {
|
|
ps -p "$1" -o pgid= 2>/dev/null | tr -d ' ' || true
|
|
}
|
|
|
|
process_parent_id() {
|
|
ps -p "$1" -o ppid= 2>/dev/null | tr -d ' ' || true
|
|
}
|
|
|
|
# Package runners may put a descendant in a nested process group (for example,
|
|
# Turbo does this before Next binds its port). Follow PPIDs so ownership still
|
|
# comes from the launcher tree instead of accepting a weaker command/user match.
|
|
process_is_descendant_of() {
|
|
local pid=$1 ancestor=$2 parent depth=0
|
|
[ -n "$pid" ] && [ -n "$ancestor" ] || return 1
|
|
while [ "$depth" -lt 64 ]; do
|
|
[ "$pid" = "$ancestor" ] && return 0
|
|
[ "$pid" != 1 ] || return 1
|
|
parent="$(process_parent_id "$pid")"
|
|
[ -n "$parent" ] && [ "$parent" != "$pid" ] || return 1
|
|
pid="$parent"
|
|
depth=$((depth + 1))
|
|
done
|
|
return 1
|
|
}
|
|
|
|
listener_pid_belongs_to_component() {
|
|
local component=$1 listener=$2 launcher recorded
|
|
launcher="$(component_pid "$component" || true)"
|
|
[ -n "$launcher" ] && [ -n "$listener" ] || return 1
|
|
recorded="$(cat "$(listener_pid_file "$component")" 2>/dev/null || true)"
|
|
[ -n "$recorded" ] && [ "$listener" = "$recorded" ] && return 0
|
|
[ "$(process_group_id "$listener")" = "$launcher" ] && return 0
|
|
process_is_descendant_of "$listener" "$launcher"
|
|
}
|
|
|
|
listener_belongs_to_component() {
|
|
local component=$1 port=$2 listener
|
|
listener="$(port_listener_pid "$port")"
|
|
listener_pid_belongs_to_component "$component" "$listener"
|
|
}
|
|
|
|
# Persist only listeners whose live process tree proves component ownership.
|
|
# stop_component can then clean up a nested process group after its launcher has
|
|
# exited and the listener has been reparented.
|
|
record_component_listener() {
|
|
local component=$1 port=$2 listener
|
|
listener="$(port_listener_pid "$port")"
|
|
listener_pid_belongs_to_component "$component" "$listener" || return 1
|
|
printf '%s\n' "$listener" > "$(listener_pid_file "$component")"
|
|
printf '%s' "$listener"
|
|
}
|
|
|
|
health_belongs_to_api() {
|
|
local health=$1 health_pid listener
|
|
health_pid="$(json_field "$health" pid || true)"
|
|
listener="$(port_listener_pid "$BACKEND_PORT")"
|
|
[ -n "$health_pid" ] && [ "$health_pid" = "$listener" ] \
|
|
&& listener_belongs_to_component api "$BACKEND_PORT"
|
|
}
|
|
|
|
api_identity_matches() {
|
|
local health=$1 expected_commit=$2 launched_at=${3:-0} reported_commit started_at
|
|
health_belongs_to_api "$health" || return 1
|
|
reported_commit="$(json_field "$health" commit || true)"
|
|
started_at="$(json_field "$health" started_at || true)"
|
|
[ -n "$started_at" ] && [ "$reported_commit" = "$expected_commit" ] || return 1
|
|
[ "$launched_at" = 0 ] || api_started_after "$health" "$launched_at"
|
|
}
|
|
|
|
start_api() {
|
|
local launched_at health waited=0 expected_commit
|
|
expected_commit="$(checkout_commit)"
|
|
if health="$(health_json)" && [ -n "$health" ] && component_pid api >/dev/null; then
|
|
if api_identity_matches "$health" "$expected_commit"; then
|
|
record_component_listener api "$BACKEND_PORT" >/dev/null \
|
|
|| die "The API listener changed while its identity was being verified. Refusing to reuse it."
|
|
ok "api already running on :$BACKEND_PORT (pid $(json_field "$health" pid), commit $expected_commit)"
|
|
return 0
|
|
fi
|
|
if health_belongs_to_api "$health"; then
|
|
warn "api on :$BACKEND_PORT is ours but not commit $expected_commit; restarting it."
|
|
stop_component api
|
|
else
|
|
die "Port $BACKEND_PORT answers /health, but its pid/commit does not match this environment. Refusing to reuse or kill it."
|
|
fi
|
|
fi
|
|
if ! port_free "$BACKEND_PORT"; then
|
|
die "Port $BACKEND_PORT is busy: $(describe_port_owner "$BACKEND_PORT").
|
|
Run 'make down' here first — a leftover instance answers /health with 200 and you would test it instead of your build."
|
|
fi
|
|
|
|
launched_at="$(now_epoch)"
|
|
launch_detached api make -C "$REPO_ROOT" -s api-dev ENV_FILE="$ENV_FILE"
|
|
info "api launching (pid $(cat "$(pid_file api)")), log: $(log_file api)"
|
|
|
|
while [ "$waited" -lt 300 ]; do
|
|
health="$(health_json || true)"
|
|
if [ -n "$health" ]; then
|
|
# A 200 is not enough: pid, process group, commit and launch time all have
|
|
# to identify the process this environment just started.
|
|
if ! api_identity_matches "$health" "$expected_commit" "$launched_at"; then
|
|
stop_component api
|
|
die "Something else is serving :$BACKEND_PORT, or the launched api did not report pid/commit/started_at for commit $expected_commit."
|
|
fi
|
|
if ! record_component_listener api "$BACKEND_PORT" >/dev/null; then
|
|
stop_component api
|
|
die "The API listener changed while its identity was being recorded."
|
|
fi
|
|
ok "api healthy at http://localhost:$BACKEND_PORT (pid $(json_field "$health" pid), commit $expected_commit)"
|
|
return 0
|
|
fi
|
|
component_pid api >/dev/null || { tail -20 "$(log_file api)" | sed 's/^/ /' >&2; die "api exited during startup. Log: $(log_file api)"; }
|
|
sleep 2
|
|
waited=$((waited + 2))
|
|
done
|
|
die "api never became healthy. Log: $(log_file api)"
|
|
}
|
|
|
|
start_web() {
|
|
local waited=0 listener
|
|
if curl -sf --max-time 15 "http://localhost:${FRONTEND_PORT}" >/dev/null 2>&1; then
|
|
listener="$(record_component_listener web "$FRONTEND_PORT" || true)"
|
|
if [ -n "$listener" ]; then
|
|
ok "web already running on :$FRONTEND_PORT (pid $listener)"
|
|
return 0
|
|
fi
|
|
fi
|
|
if ! port_free "$FRONTEND_PORT"; then
|
|
die "Port $FRONTEND_PORT is busy: $(describe_port_owner "$FRONTEND_PORT"). Run 'make down' here first."
|
|
fi
|
|
|
|
launch_detached web make -C "$REPO_ROOT" -s web-dev ENV_FILE="$ENV_FILE"
|
|
info "web launching (pid $(cat "$(pid_file web)")), log: $(log_file web)"
|
|
|
|
while [ "$waited" -lt 300 ]; do
|
|
if curl -sf --max-time 15 "http://localhost:${FRONTEND_PORT}" >/dev/null 2>&1; then
|
|
listener="$(record_component_listener web "$FRONTEND_PORT" || true)"
|
|
if [ -z "$listener" ]; then
|
|
stop_component web
|
|
die "Web on :$FRONTEND_PORT is not descended from the process this environment launched."
|
|
fi
|
|
ok "web serving http://localhost:$FRONTEND_PORT (pid ${listener:-?})"
|
|
return 0
|
|
fi
|
|
component_pid web >/dev/null || { tail -20 "$(log_file web)" | sed 's/^/ /' >&2; die "web exited during startup. Log: $(log_file web)"; }
|
|
sleep 2
|
|
waited=$((waited + 2))
|
|
done
|
|
die "web never came up. Log: $(log_file web)"
|
|
}
|
|
|
|
# send-code once, verify-code once. Repeated verify attempts lock the code out
|
|
# and start returning 400 even when it is correct, so retrying is self-defeating.
|
|
write_profile_config() {
|
|
local config=$1 pat=$2 ws=$3
|
|
mkdir -p "$PROFILE_DIR"
|
|
cat > "$config" <<EOF
|
|
{
|
|
"server_url": "http://localhost:${BACKEND_PORT}",
|
|
"app_url": "http://localhost:${FRONTEND_PORT}",
|
|
"token": "$(json_escape "$pat")",
|
|
"workspace_id": "$(json_escape "$ws")",
|
|
"workspaces_root": "$(json_escape "$WORKSPACES_ROOT")"
|
|
}
|
|
EOF
|
|
chmod 600 "$config"
|
|
}
|
|
|
|
ensure_credentials() {
|
|
local server="http://localhost:${BACKEND_PORT}" config="$PROFILE_DIR/config.json"
|
|
local code="${MULTICA_DEV_VERIFICATION_CODE:-$DEV_CODE_DEFAULT}"
|
|
local verify jwt pat ws
|
|
|
|
if [ -f "$config" ]; then
|
|
pat="$(json_field "$(cat "$config")" token || true)"
|
|
ws="$(json_field "$(cat "$config")" workspace_id || true)"
|
|
if [ -n "$pat" ] && curl -sf --max-time 5 "$server/api/me" -H "Authorization: Bearer $pat" >/dev/null 2>&1; then
|
|
WORKSPACE_ID="$ws"
|
|
write_profile_config "$config" "$pat" "$ws"
|
|
ok "CLI profile $PROFILE already authenticated"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
curl -sf -X POST "$server/auth/send-code" -H 'Content-Type: application/json' \
|
|
-d "{\"email\":\"${DEV_EMAIL}\"}" >/dev/null \
|
|
|| die "send-code failed. Is MULTICA_DEV_VERIFICATION_CODE set and APP_ENV non-production?"
|
|
|
|
verify="$(curl -sS -X POST "$server/auth/verify-code" -H 'Content-Type: application/json' \
|
|
-d "{\"email\":\"${DEV_EMAIL}\",\"code\":\"${code}\"}")"
|
|
jwt="$(json_field "$verify" token || true)"
|
|
[ -n "$jwt" ] || die "verify-code failed: $verify
|
|
Do not retry immediately — repeated attempts lock the code. Wait ~40s and re-run."
|
|
|
|
local pat_response
|
|
pat_response="$(curl -sS -X POST "$server/api/tokens" -H "Authorization: Bearer $jwt" \
|
|
-H 'Content-Type: application/json' -d '{"name":"dev-env","expires_in_days":365}')"
|
|
pat="$(json_field "$pat_response" token || true)"
|
|
[ -n "$pat" ] || die "Personal access token creation failed: $pat_response"
|
|
|
|
local ws_response
|
|
ws_response="$(curl -sS -X POST "$server/api/workspaces" -H "Authorization: Bearer $pat" \
|
|
-H 'Content-Type: application/json' \
|
|
-d "{\"name\":\"${WORKSPACE_NAME}\",\"slug\":\"${WORKSPACE_SLUG}\"}")"
|
|
ws="$(json_field "$ws_response" id || true)"
|
|
if [ -z "$ws" ]; then
|
|
ws="$(node -e '
|
|
let list;
|
|
try { list = JSON.parse(process.argv[1]); } catch { process.exit(1); }
|
|
const match = (Array.isArray(list) ? list : []).find(w => w.slug === process.argv[2]);
|
|
if (!match) process.exit(1);
|
|
process.stdout.write(match.id);
|
|
' "$(curl -sS "$server/api/workspaces" -H "Authorization: Bearer $pat")" "$WORKSPACE_SLUG" 2>/dev/null || true)"
|
|
fi
|
|
[ -n "$ws" ] || die "Workspace creation failed: $ws_response"
|
|
|
|
# A fresh user has onboarded_at = NULL and a browser login is bounced to
|
|
# /onboarding, so the URL this script prints would not land in the app.
|
|
curl -sS -X POST "$server/api/me/onboarding/complete" \
|
|
-H "Authorization: Bearer $pat" -H "X-Workspace-ID: $ws" \
|
|
-H 'Content-Type: application/json' -d '{"exit":"existing"}' >/dev/null 2>&1 || true
|
|
|
|
write_profile_config "$config" "$pat" "$ws"
|
|
WORKSPACE_ID="$ws"
|
|
ok "Logged in as $DEV_EMAIL and wrote profile $PROFILE"
|
|
}
|
|
|
|
# The CLI refuses `daemon start` anywhere under a daemon-task marker, so a task
|
|
# cannot spawn a second daemon that competes for its own work. Checking the
|
|
# marker here turns that refusal into one actionable line, before this component
|
|
# has spent a login and a CLI build on an outcome it cannot reach.
|
|
daemon_task_marker() {
|
|
local dir="$REPO_ROOT" marker
|
|
while :; do
|
|
marker="$dir/.multica/daemon_task_context.json"
|
|
if [ -f "$marker" ] && grep -q 'multica-daemon-task' "$marker" 2>/dev/null; then
|
|
printf '%s' "$marker"
|
|
return 0
|
|
fi
|
|
[ "$dir" != "/" ] || break
|
|
dir="$(dirname "$dir")"
|
|
done
|
|
return 0
|
|
}
|
|
|
|
start_daemon() {
|
|
local status state
|
|
ensure_credentials
|
|
|
|
# Built, never `go run`: the daemon records its own executable path at startup
|
|
# and re-execs it as the execution-environment helper for every task. Under
|
|
# `go run` the toolchain deletes that binary when the launcher exits, so the
|
|
# daemon registers, heartbeats, and then fails every task with
|
|
# "fork/exec .../go-build.../exe/multica: no such file or directory".
|
|
info "Building $MULTICA_BIN (a go run daemon would fail every task later)."
|
|
(cd "$REPO_ROOT/server" && go build -o bin/multica ./cmd/multica) || die "Failed to build the multica CLI."
|
|
|
|
"${CLEAN_ENV[@]}" MULTICA_WORKSPACES_ROOT="$WORKSPACES_ROOT" \
|
|
"$MULTICA_BIN" daemon start --profile "$PROFILE" 2>&1 | sed 's/^/ /' || true
|
|
|
|
status="$("${CLEAN_ENV[@]}" MULTICA_WORKSPACES_ROOT="$WORKSPACES_ROOT" \
|
|
"$MULTICA_BIN" daemon status --profile "$PROFILE" --output json 2>/dev/null || true)"
|
|
state="$(json_field "$status" status || echo unknown)"
|
|
# `daemon status` reports "stopped" plus port_conflict when the daemon
|
|
# answering this profile's health port belongs to another profile, so a
|
|
# collision can never be read here as a healthy daemon.
|
|
if [ "$state" != running ]; then
|
|
if [ -n "$(json_field "$status" port_conflict.profile || true)" ]; then
|
|
die "The health port for $PROFILE is served by profile $(json_field "$status" port_conflict.profile). Rename one of them."
|
|
fi
|
|
die "Daemon is '$state' after start. Log: $PROFILE_DIR/daemon.log"
|
|
fi
|
|
ok "daemon running for profile $PROFILE (pid $(json_field "$status" pid || echo '?'))"
|
|
}
|
|
|
|
start_desktop() {
|
|
local waited=0 listener stable_listener
|
|
if component_pid desktop >/dev/null \
|
|
&& curl -sf --max-time 10 "http://localhost:${DESKTOP_RENDERER_PORT}" >/dev/null 2>&1 \
|
|
&& listener_belongs_to_component desktop "$DESKTOP_RENDERER_PORT" \
|
|
&& desktop_env_matches; then
|
|
listener="$(record_component_listener desktop "$DESKTOP_RENDERER_PORT" || true)"
|
|
[ -n "$listener" ] || die "The Desktop listener changed while its identity was being recorded."
|
|
ok "desktop already running (pid $(component_pid desktop), renderer $listener on :$DESKTOP_RENDERER_PORT)"
|
|
return 0
|
|
fi
|
|
if component_pid desktop >/dev/null; then
|
|
warn "desktop launcher exists but its renderer/backend identity is stale; restarting it."
|
|
stop_component desktop
|
|
fi
|
|
if ! port_free "$DESKTOP_RENDERER_PORT"; then
|
|
die "Desktop renderer port $DESKTOP_RENDERER_PORT is busy: $(describe_port_owner "$DESKTOP_RENDERER_PORT")."
|
|
fi
|
|
|
|
# The marker makes destroy remove only a file this tool owns. Explicit
|
|
# renderer/app values bind Desktop to the registry allocation rather than
|
|
# independently hashing the checkout path again.
|
|
cat > "$DESKTOP_ENV_FILE" <<EOF
|
|
# Managed by scripts/dev-env.sh for environment ${NAME}.
|
|
VITE_API_URL=http://localhost:${BACKEND_PORT}
|
|
VITE_WS_URL=ws://localhost:${BACKEND_PORT}/ws
|
|
EOF
|
|
launch_detached desktop env \
|
|
DESKTOP_RENDERER_PORT="$DESKTOP_RENDERER_PORT" DESKTOP_APP_SUFFIX="$DESKTOP_APP_SUFFIX" \
|
|
make -C "$REPO_ROOT" -s desktop-dev ENV_FILE="$ENV_FILE"
|
|
|
|
while [ "$waited" -lt 300 ]; do
|
|
if curl -sf --max-time 10 "http://localhost:${DESKTOP_RENDERER_PORT}" >/dev/null 2>&1; then
|
|
listener="$(port_listener_pid "$DESKTOP_RENDERER_PORT")"
|
|
if ! listener_pid_belongs_to_component desktop "$listener" || ! desktop_env_matches; then
|
|
stop_component desktop
|
|
die "Desktop renderer on :$DESKTOP_RENDERER_PORT does not belong to this environment."
|
|
fi
|
|
# Electron can bring Vite up and then crash during renderer bootstrap.
|
|
# Require a short stable window before the environment claims readiness.
|
|
sleep 5
|
|
stable_listener="$(port_listener_pid "$DESKTOP_RENDERER_PORT")"
|
|
if ! component_pid desktop >/dev/null || [ "$stable_listener" != "$listener" ] \
|
|
|| ! curl -sf --max-time 10 "http://localhost:${DESKTOP_RENDERER_PORT}" >/dev/null 2>&1; then
|
|
tail -20 "$(log_file desktop)" | sed 's/^/ /' >&2 || true
|
|
stop_component desktop
|
|
die "desktop exited during renderer bootstrap. Log: $(log_file desktop)"
|
|
fi
|
|
printf '%s\n' "$listener" > "$(listener_pid_file desktop)"
|
|
ok "desktop ready (launcher $(component_pid desktop), renderer pid ${listener:-?}, backend :$BACKEND_PORT)"
|
|
return 0
|
|
fi
|
|
component_pid desktop >/dev/null \
|
|
|| { tail -20 "$(log_file desktop)" | sed 's/^/ /' >&2; die "desktop exited during startup. Log: $(log_file desktop)"; }
|
|
sleep 2
|
|
waited=$((waited + 2))
|
|
done
|
|
stop_component desktop
|
|
die "desktop renderer never became ready on :$DESKTOP_RENDERER_PORT. Log: $(log_file desktop)"
|
|
}
|
|
|
|
desktop_env_matches() {
|
|
[ -f "$DESKTOP_ENV_FILE" ] \
|
|
&& grep -Fqx "# Managed by scripts/dev-env.sh for environment ${NAME}." "$DESKTOP_ENV_FILE" \
|
|
&& grep -Fqx "VITE_API_URL=http://localhost:${BACKEND_PORT}" "$DESKTOP_ENV_FILE" \
|
|
&& grep -Fqx "VITE_WS_URL=ws://localhost:${BACKEND_PORT}/ws" "$DESKTOP_ENV_FILE"
|
|
}
|
|
|
|
stop_component() {
|
|
local name=$1 pid launcher="" status state recorded_listener="" port="" listener=""
|
|
case "$name" in
|
|
daemon)
|
|
if [ -x "$MULTICA_BIN" ]; then
|
|
if "${CLEAN_ENV[@]}" MULTICA_WORKSPACES_ROOT="$WORKSPACES_ROOT" \
|
|
"$MULTICA_BIN" daemon stop --profile "$PROFILE" >/dev/null 2>&1; then
|
|
ok "daemon stopped"
|
|
else
|
|
status="$("${CLEAN_ENV[@]}" MULTICA_WORKSPACES_ROOT="$WORKSPACES_ROOT" \
|
|
"$MULTICA_BIN" daemon status --profile "$PROFILE" --output json 2>/dev/null || true)"
|
|
state="$(json_field "$status" status || echo stopped)"
|
|
if [ "$state" = running ]; then
|
|
warn "daemon for profile $PROFILE is still running"
|
|
return 1
|
|
fi
|
|
info "daemon was not running"
|
|
fi
|
|
else
|
|
pid="$(cat "$PROFILE_DIR/daemon.pid" 2>/dev/null || true)"
|
|
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
|
warn "cannot stop daemon pid $pid because no usable multica binary was found"
|
|
return 1
|
|
fi
|
|
info "daemon skipped (no usable binary and no live profile pid)"
|
|
fi
|
|
return 0
|
|
;;
|
|
esac
|
|
|
|
case "$name" in
|
|
api) port="$BACKEND_PORT" ;;
|
|
web) port="$FRONTEND_PORT" ;;
|
|
desktop) port="$DESKTOP_RENDERER_PORT" ;;
|
|
esac
|
|
recorded_listener="$(cat "$(listener_pid_file "$name")" 2>/dev/null || true)"
|
|
pid="$(component_pid "$name" || true)"
|
|
if [ -n "$pid" ]; then
|
|
launcher="$pid"
|
|
# Capture an older environment's listener before killing the launcher. A
|
|
# nested process group may survive that signal and then lose its PPID chain
|
|
# when the launcher exits, so it must be proven and recorded first.
|
|
if [ -n "$port" ]; then
|
|
listener="$(port_listener_pid "$port")"
|
|
if [ -n "$listener" ] && listener_pid_belongs_to_component "$name" "$listener"; then
|
|
recorded_listener="$listener"
|
|
printf '%s\n' "$listener" > "$(listener_pid_file "$name")"
|
|
fi
|
|
fi
|
|
# Negative pid targets the process group, so make → go run → server all go
|
|
# down together instead of leaving the real listener orphaned.
|
|
kill -TERM -"$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
|
|
sleep 1
|
|
if kill -0 "$pid" 2>/dev/null; then
|
|
kill -KILL -"$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true
|
|
sleep 1
|
|
fi
|
|
if kill -0 "$pid" 2>/dev/null; then
|
|
warn "$name launcher pid $pid is still running"
|
|
return 1
|
|
fi
|
|
rm -f "$(pid_file "$name")"
|
|
ok "$name stopped (pid $pid)"
|
|
else
|
|
rm -f "$(pid_file "$name")"
|
|
info "$name was not running"
|
|
fi
|
|
|
|
# A process group kill can miss a nested listener that has reparented away
|
|
# from its launcher. Only kill the listener captured above or one still in
|
|
# the launcher's direct process group; never infer ownership from the port.
|
|
if [ -n "$port" ]; then
|
|
listener="$(port_listener_pid "$port")"
|
|
if [ -n "$listener" ]; then
|
|
if { [ -n "$recorded_listener" ] && [ "$listener" = "$recorded_listener" ]; } \
|
|
|| { [ -n "$launcher" ] && [ "$(process_group_id "$listener")" = "$launcher" ]; }; then
|
|
kill -TERM "$listener" 2>/dev/null || true
|
|
sleep 1
|
|
if kill -0 "$listener" 2>/dev/null; then
|
|
kill -KILL "$listener" 2>/dev/null || true
|
|
sleep 1
|
|
fi
|
|
if kill -0 "$listener" 2>/dev/null; then
|
|
warn "$name listener pid $listener is still running"
|
|
return 1
|
|
fi
|
|
info "released :$port (pid $listener)"
|
|
else
|
|
warn "left :$port alone: listener pid $listener is not owned by this environment"
|
|
fi
|
|
fi
|
|
fi
|
|
rm -f "$(listener_pid_file "$name")"
|
|
}
|
|
|
|
# ------------------------------------------------------------------ status ---
|
|
|
|
component_state() {
|
|
case "$1" in
|
|
api)
|
|
local health
|
|
health="$(health_json || true)"
|
|
if [ -n "$health" ] && api_identity_matches "$health" "$(checkout_commit)"; then
|
|
printf 'running|http://localhost:%s|pid %s commit %s started %s' "$BACKEND_PORT" \
|
|
"$(json_field "$health" pid || echo '?')" \
|
|
"$(json_field "$health" commit || echo '?')" \
|
|
"$(json_field "$health" started_at || echo '?')"
|
|
elif [ -n "$health" ]; then
|
|
printf 'mismatch|http://localhost:%s|health responder is not this checkout/process' "$BACKEND_PORT"
|
|
else
|
|
printf 'stopped|http://localhost:%s|' "$BACKEND_PORT"
|
|
fi
|
|
;;
|
|
web)
|
|
if curl -sf --max-time 10 "http://localhost:${FRONTEND_PORT}" >/dev/null 2>&1 \
|
|
&& listener_belongs_to_component web "$FRONTEND_PORT"; then
|
|
printf 'running|http://localhost:%s|pid %s' "$FRONTEND_PORT" "$(port_listener_pid "$FRONTEND_PORT")"
|
|
elif [ -n "$(port_listener_pid "$FRONTEND_PORT")" ]; then
|
|
printf 'mismatch|http://localhost:%s|listener is not owned by this environment' "$FRONTEND_PORT"
|
|
else
|
|
printf 'stopped|http://localhost:%s|' "$FRONTEND_PORT"
|
|
fi
|
|
;;
|
|
daemon)
|
|
local status state
|
|
if [ -x "$MULTICA_BIN" ]; then
|
|
status="$("${CLEAN_ENV[@]}" MULTICA_WORKSPACES_ROOT="$WORKSPACES_ROOT" \
|
|
"$MULTICA_BIN" daemon status --profile "$PROFILE" --output json 2>/dev/null || true)"
|
|
state="$(json_field "$status" status || echo stopped)"
|
|
printf '%s|%s|pid %s' "$state" "$PROFILE" "$(json_field "$status" pid || echo '-')"
|
|
else
|
|
printf 'stopped|%s|not built' "$PROFILE"
|
|
fi
|
|
;;
|
|
desktop)
|
|
local pid
|
|
pid="$(component_pid desktop || true)"
|
|
if [ -n "$pid" ] \
|
|
&& curl -sf --max-time 10 "http://localhost:${DESKTOP_RENDERER_PORT}" >/dev/null 2>&1 \
|
|
&& listener_belongs_to_component desktop "$DESKTOP_RENDERER_PORT" \
|
|
&& desktop_env_matches; then
|
|
printf 'running|http://localhost:%s|launcher %s renderer %s' \
|
|
"$DESKTOP_RENDERER_PORT" "$pid" "$(port_listener_pid "$DESKTOP_RENDERER_PORT")"
|
|
elif [ -n "$pid" ] || [ -n "$(port_listener_pid "$DESKTOP_RENDERER_PORT")" ]; then
|
|
printf 'mismatch|http://localhost:%s|renderer/backend identity does not match' "$DESKTOP_RENDERER_PORT"
|
|
else
|
|
printf 'stopped|http://localhost:%s|' "$DESKTOP_RENDERER_PORT"
|
|
fi
|
|
;;
|
|
esac
|
|
}
|
|
|
|
print_status_human() {
|
|
local comp state url detail row
|
|
printf '\n%s%s%s %s%s%s\n' "$C_BOLD" "$NAME" "$C_OFF" "$C_DIM" "$DIR" "$C_OFF"
|
|
printf ' %-9s %-9s %-32s %s\n' COMPONENT STATE ADDRESS DETAIL
|
|
for comp in $ALL_COMPONENTS; do
|
|
row="$(component_state "$comp")"
|
|
state="${row%%|*}"; row="${row#*|}"
|
|
url="${row%%|*}"; detail="${row#*|}"
|
|
printf ' %-9s %-9s %-32s %s\n' "$comp" "$state" "${url:--}" "${detail:--}"
|
|
done
|
|
printf ' %-9s %-9s %-32s %s\n' database "$(database_state)" "$DB_NAME" "${DATABASE_URL%%\?*}"
|
|
printf '\n owner %s · created %s%s\n' "$OWNER" "$CREATED_AT" "$( [ "${TTL_HOURS:-0}" != 0 ] && printf ' · expires %s' "$EXPIRES_AT" )"
|
|
printf ' logs %s\n' "$LOG_DIR"
|
|
}
|
|
|
|
# Reported from the connection string the application uses, so "present" can
|
|
# never mean "present in a server nothing talks to".
|
|
database_state() {
|
|
local admin_url
|
|
command -v psql >/dev/null 2>&1 || { printf 'unknown'; return; }
|
|
admin_url="$(admin_database_url "$DATABASE_URL")"
|
|
PGCONNECT_TIMEOUT=3 psql "$admin_url" -tAc 'SELECT 1' >/dev/null 2>&1 || { printf 'no-server'; return; }
|
|
if PGCONNECT_TIMEOUT=3 psql "$admin_url" -tAc "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" 2>/dev/null | grep -q 1; then
|
|
printf 'present'
|
|
else
|
|
printf 'missing'
|
|
fi
|
|
}
|
|
|
|
print_status_json() {
|
|
local comp row state url detail first=1
|
|
printf '{"name":"%s","dir":"%s","owner":"%s","created_at":"%s","ttl_hours":%s,"expires_at":"%s",' \
|
|
"$(json_escape "$NAME")" "$(json_escape "$DIR")" "$(json_escape "$OWNER")" \
|
|
"$(json_escape "$CREATED_AT")" "${TTL_HOURS:-0}" "$(json_escape "$EXPIRES_AT")"
|
|
printf '"backend_port":%s,"frontend_port":%s,"desktop_renderer_port":%s,"database":"%s","profile":"%s","env_file":"%s","logs":"%s","components":{' \
|
|
"$BACKEND_PORT" "$FRONTEND_PORT" "$DESKTOP_RENDERER_PORT" "$(json_escape "$DB_NAME")" "$(json_escape "$PROFILE")" \
|
|
"$(json_escape "$ENV_FILE")" "$(json_escape "$LOG_DIR")"
|
|
for comp in $ALL_COMPONENTS; do
|
|
row="$(component_state "$comp")"
|
|
state="${row%%|*}"; row="${row#*|}"
|
|
url="${row%%|*}"; detail="${row#*|}"
|
|
[ "$first" = 1 ] || printf ','
|
|
first=0
|
|
printf '"%s":{"state":"%s","address":"%s","detail":"%s"}' \
|
|
"$comp" "$(json_escape "$state")" "$(json_escape "$url")" "$(json_escape "$detail")"
|
|
done
|
|
printf '}}\n'
|
|
}
|
|
|
|
print_handoff() {
|
|
local entrypoint
|
|
if component_selected web; then
|
|
entrypoint="Open http://localhost:${FRONTEND_PORT}/${WORKSPACE_SLUG}/issues"
|
|
elif component_selected desktop; then
|
|
entrypoint="Desktop renderer http://localhost:${DESKTOP_RENDERER_PORT} → backend :${BACKEND_PORT}"
|
|
else
|
|
entrypoint="API only http://localhost:${BACKEND_PORT}"
|
|
fi
|
|
cat <<EOF
|
|
|
|
${C_GREEN}✓ Environment ready.${C_OFF}
|
|
|
|
${entrypoint}
|
|
Sign in ${DEV_EMAIL} · code ${MULTICA_DEV_VERIFICATION_CODE:-$DEV_CODE_DEFAULT}
|
|
Backend http://localhost:${BACKEND_PORT} (GET /health reports pid + commit + started_at)
|
|
Commit $(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)
|
|
Environment ${NAME}$( [ "${TTL_HOURS:-0}" != 0 ] && printf ' (expires %s)' "$EXPIRES_AT" )
|
|
|
|
Inspect make status
|
|
Stop make down (keeps the database, restarts in seconds)
|
|
Delete make destroy (drops the database and frees the slot)
|
|
EOF
|
|
}
|
|
|
|
# ------------------------------------------------------------------- verbs ---
|
|
|
|
resolve_env_for_read() {
|
|
local name="${1:-}"
|
|
if [ -z "$name" ]; then
|
|
name="$(env_name_for_dir "$REPO_ROOT" || true)"
|
|
[ -n "$name" ] || die "No environment registered for $REPO_ROOT. Run 'make up' first."
|
|
fi
|
|
require_env_name "$name"
|
|
load_manifest "$name" || die "Unknown environment '$name'. Run 'make list' to see what exists."
|
|
bind_paths
|
|
}
|
|
|
|
bind_paths() {
|
|
STATE_DIR="$(env_dir "$NAME")"
|
|
LOG_DIR="$STATE_DIR/logs"
|
|
PROFILE_DIR="$DEV_PROFILES_HOME/$PROFILE"
|
|
WORKSPACES_ROOT="${WORKSPACES_ROOT:-$DEV_WORKSPACES_PARENT/multica_workspaces_$PROFILE}"
|
|
DESKTOP_RENDERER_PORT="${DESKTOP_RENDERER_PORT:-$(renderer_port_for_offset "$OFFSET")}"
|
|
DESKTOP_APP_SUFFIX="${DESKTOP_APP_SUFFIX:-$NAME}"
|
|
DESKTOP_USER_DATA_DIR="${DESKTOP_USER_DATA_DIR:-$(desktop_user_data_dir "$DESKTOP_APP_SUFFIX")}"
|
|
DESKTOP_ENV_FILE="${DESKTOP_ENV_FILE:-$DIR/apps/desktop/.env.development.local}"
|
|
EXPIRES_AT="${EXPIRES_AT:-}"
|
|
MULTICA_BIN="$DIR/server/bin/multica"
|
|
if [ ! -x "$MULTICA_BIN" ] && [ -x "$REPO_ROOT/server/bin/multica" ]; then
|
|
MULTICA_BIN="$REPO_ROOT/server/bin/multica"
|
|
fi
|
|
mkdir -p "$LOG_DIR"
|
|
}
|
|
|
|
save_manifest() {
|
|
{
|
|
write_manifest_value NAME "$NAME"
|
|
write_manifest_value DIR "$DIR"
|
|
write_manifest_value CREATED_AT "$CREATED_AT"
|
|
write_manifest_value OWNER "$OWNER"
|
|
write_manifest_value TTL_HOURS "$TTL_HOURS"
|
|
write_manifest_value EXPIRES_AT "$EXPIRES_AT"
|
|
write_manifest_value ENV_FILE "$ENV_FILE"
|
|
write_manifest_value OFFSET "$OFFSET"
|
|
write_manifest_value BACKEND_PORT "$BACKEND_PORT"
|
|
write_manifest_value FRONTEND_PORT "$FRONTEND_PORT"
|
|
write_manifest_value DB_NAME "$DB_NAME"
|
|
write_manifest_value DATABASE_URL "$DATABASE_URL"
|
|
write_manifest_value PROFILE "$PROFILE"
|
|
write_manifest_value WORKSPACES_ROOT "$WORKSPACES_ROOT"
|
|
write_manifest_value DESKTOP_RENDERER_PORT "$DESKTOP_RENDERER_PORT"
|
|
write_manifest_value DESKTOP_APP_SUFFIX "$DESKTOP_APP_SUFFIX"
|
|
write_manifest_value DESKTOP_USER_DATA_DIR "$DESKTOP_USER_DATA_DIR"
|
|
write_manifest_value DESKTOP_ENV_FILE "$DESKTOP_ENV_FILE"
|
|
} > "$(manifest_of "$NAME")"
|
|
}
|
|
|
|
cmd_up() {
|
|
local requested="$DEFAULT_COMPONENTS" name="" owner=human ttl=0 lifecycle_requested=0 comp
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--components|-c) requested="$(printf '%s' "$2" | tr ',' ' ')"; shift 2 ;;
|
|
--all) requested="$ALL_COMPONENTS"; shift ;;
|
|
--name) name="$2"; shift 2 ;;
|
|
--ephemeral) owner=agent; lifecycle_requested=1; [ "$ttl" != 0 ] || ttl=24; shift ;;
|
|
--ttl) ttl="$2"; owner=agent; lifecycle_requested=1; shift 2 ;;
|
|
*) die "Unknown flag for up: $1" ;;
|
|
esac
|
|
done
|
|
|
|
[ -z "$name" ] || require_env_name "$name"
|
|
[ "$ttl" = 0 ] || require_ttl "$ttl"
|
|
|
|
for comp in $requested; do
|
|
case " $ALL_COMPONENTS " in *" $comp "*) ;; *) die "Unknown component '$comp'. Valid: $ALL_COMPONENTS" ;; esac
|
|
done
|
|
# web, daemon and desktop are all clients of the backend; selecting one
|
|
# without api would produce an environment that cannot serve a single request.
|
|
case " $requested " in *" api "*) ;; *) requested="api $requested" ;; esac
|
|
COMPONENTS="$requested"
|
|
|
|
# No resident cleanup service is required: every future environment start is
|
|
# a safe opportunity to collect expired or directory-less environments.
|
|
cmd_gc --auto
|
|
|
|
step "Prerequisites"
|
|
local missing=() tool needed="node go curl"
|
|
# pnpm is only required by the components that actually build JavaScript, so
|
|
# `up C=api` works on a checkout that has never run an install.
|
|
if component_selected web || component_selected desktop; then needed="$needed pnpm"; fi
|
|
for tool in $needed; do
|
|
command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
|
|
done
|
|
[ ${#missing[@]} -eq 0 ] || die "Missing prerequisites: ${missing[*]}"
|
|
mkdir -p "$DEV_TMPDIR"
|
|
if [ "${TMPDIR:-}" != "$DEV_TMPDIR" ]; then
|
|
info "TMPDIR pinned to $DEV_TMPDIR (was ${TMPDIR:-<unset>}) so builds outlive the run that made them."
|
|
export TMPDIR="$DEV_TMPDIR" TMP="$DEV_TMPDIR" TEMP="$DEV_TMPDIR"
|
|
fi
|
|
ok "node, go, curl found"
|
|
|
|
if component_selected daemon; then
|
|
local marker
|
|
marker="$(daemon_task_marker)"
|
|
[ -z "$marker" ] || die "The daemon component cannot start under a daemon-managed task.
|
|
This checkout sits below $marker, and the CLI refuses 'daemon start' there so a
|
|
task cannot spawn a second daemon competing for its own work.
|
|
Start the rest with 'make up C=api,web', or run 'make up C=daemon' from your own shell."
|
|
fi
|
|
|
|
step "Environment"
|
|
ENV_FILE="$(detect_env_file)"
|
|
if [ ! -f "$REPO_ROOT/$ENV_FILE" ]; then
|
|
if [ "$ENV_FILE" = .env.worktree ]; then
|
|
bash "$REPO_ROOT/scripts/init-worktree-env.sh" "$ENV_FILE" >/dev/null
|
|
else
|
|
cp "$REPO_ROOT/.env.example" "$REPO_ROOT/$ENV_FILE"
|
|
fi
|
|
info "Created $ENV_FILE"
|
|
fi
|
|
ensure_dev_code "$ENV_FILE"
|
|
load_env_file "$ENV_FILE"
|
|
|
|
acquire_lock
|
|
trap release_lock EXIT
|
|
local existing offset
|
|
existing="$(env_name_for_dir "$REPO_ROOT" || true)"
|
|
if [ -n "$existing" ] && [ -z "$name" ]; then
|
|
name="$existing"
|
|
fi
|
|
|
|
if [ -n "$existing" ]; then
|
|
load_manifest "$existing"
|
|
NAME="$existing"
|
|
bind_paths
|
|
if [ "$lifecycle_requested" = 1 ]; then
|
|
OWNER="$owner"
|
|
TTL_HOURS="$ttl"
|
|
EXPIRES_AT="$(expires_at_after_hours "$ttl")"
|
|
save_manifest
|
|
fi
|
|
info "Reusing environment $NAME (ports $BACKEND_PORT/$FRONTEND_PORT, database $DB_NAME)"
|
|
else
|
|
offset=$((PORT - 18080))
|
|
# A slot is adopted only when the registry does not hold it and both ports
|
|
# are genuinely free; otherwise a fresh one is allocated and the env file is
|
|
# rewritten, which is the whole point of allocating instead of computing.
|
|
local candidate_renderer
|
|
candidate_renderer="$(renderer_port_for_offset "$offset")"
|
|
if [ "$PORT" -lt 18080 ] || [ "$FRONTEND_PORT" -ne $((13000 + offset)) ] \
|
|
|| offset_registered "$offset" || ! port_free "$PORT" \
|
|
|| ! port_free "$FRONTEND_PORT" || ! port_free "$candidate_renderer"; then
|
|
if [ "$PORT" -ge 18080 ] && { offset_registered "$offset" || ! port_free "$PORT"; }; then
|
|
warn "Slot $offset (port $PORT) is taken: $(describe_port_owner "$PORT"). Allocating another."
|
|
fi
|
|
offset="$(allocate_offset "$REPO_ROOT")" || die "No free slot left; run 'make gc' or 'make list'."
|
|
local new_backend=$((18080 + offset)) new_frontend=$((13000 + offset))
|
|
local new_db="multica_$(slugify "$(basename "$REPO_ROOT")")_${offset}"
|
|
rewrite_env_ports "$ENV_FILE" "$offset" "$new_backend" "$new_frontend" "$new_db"
|
|
load_env_file "$ENV_FILE"
|
|
info "Allocated slot $offset — backend $new_backend, frontend $new_frontend, database $new_db"
|
|
fi
|
|
|
|
NAME="${name:-$(slugify "$(basename "$REPO_ROOT")")-${offset}}"
|
|
require_env_name "$NAME"
|
|
PROFILE="dev-$(slugify "$(basename "$REPO_ROOT")")-${offset}"
|
|
WORKSPACES_ROOT="$DEV_WORKSPACES_PARENT/multica_workspaces_$PROFILE"
|
|
DESKTOP_RENDERER_PORT="$(renderer_port_for_offset "$offset")"
|
|
DESKTOP_APP_SUFFIX="$NAME"
|
|
DESKTOP_USER_DATA_DIR="$(desktop_user_data_dir "$DESKTOP_APP_SUFFIX")"
|
|
DESKTOP_ENV_FILE="$REPO_ROOT/apps/desktop/.env.development.local"
|
|
[ ! -f "$(manifest_of "$NAME")" ] || die "Environment '$NAME' already exists for a different directory."
|
|
mkdir -p "$(env_dir "$NAME")/logs"
|
|
DIR="$REPO_ROOT"
|
|
CREATED_AT="$(now_iso)"
|
|
OWNER="$owner"
|
|
TTL_HOURS="$ttl"
|
|
EXPIRES_AT=""
|
|
[ "$ttl" = 0 ] || EXPIRES_AT="$(expires_at_after_hours "$ttl")"
|
|
OFFSET="$offset"
|
|
BACKEND_PORT="$PORT"
|
|
DB_NAME="$POSTGRES_DB"
|
|
save_manifest
|
|
load_manifest "$NAME"
|
|
ok "Registered environment $NAME"
|
|
fi
|
|
release_lock
|
|
trap - EXIT
|
|
|
|
bind_paths
|
|
# The manifest is the source of truth from here on; re-export so every child
|
|
# sees the same values the registry recorded.
|
|
export PORT="$BACKEND_PORT" FRONTEND_PORT DATABASE_URL POSTGRES_DB="$DB_NAME"
|
|
|
|
if [ ! -d "$REPO_ROOT/node_modules" ] && { component_selected web || component_selected desktop; }; then
|
|
step "Dependencies"
|
|
(cd "$REPO_ROOT" && pnpm install) || die "pnpm install failed."
|
|
fi
|
|
|
|
step "Database"
|
|
ensure_database
|
|
migrate_database
|
|
ok "$DB_NAME reachable through DATABASE_URL and migrated"
|
|
|
|
step "Components: $COMPONENTS"
|
|
component_selected api && start_api
|
|
component_selected web && start_web
|
|
component_selected daemon && start_daemon
|
|
component_selected desktop && start_desktop
|
|
|
|
print_handoff
|
|
}
|
|
|
|
cmd_down() {
|
|
local name="" requested="$ALL_COMPONENTS"
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--components|-c) requested="$(printf '%s' "$2" | tr ',' ' ')"; shift 2 ;;
|
|
-*) die "Unknown flag for down: $1" ;;
|
|
*) name="$1"; shift ;;
|
|
esac
|
|
done
|
|
resolve_env_for_read "$name"
|
|
export PORT="$BACKEND_PORT" FRONTEND_PORT DATABASE_URL POSTGRES_DB="$DB_NAME"
|
|
|
|
step "Stopping $NAME: $requested"
|
|
local comp
|
|
for comp in $requested; do
|
|
case " $ALL_COMPONENTS " in *" $comp "*) ;; *) die "Unknown component '$comp'. Valid: $ALL_COMPONENTS" ;; esac
|
|
stop_component "$comp"
|
|
done
|
|
printf '\n%s✓ %s stopped.%s Database, profile and slot kept — `make up` restarts in seconds.\n' "$C_GREEN" "$NAME" "$C_OFF"
|
|
}
|
|
|
|
cmd_destroy() {
|
|
local name="" assume_yes=0 reply admin_url failures=0
|
|
local expected_workspaces expected_desktop_data
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--yes|-y) assume_yes=1; shift ;;
|
|
-*) die "Unknown flag for destroy: $1" ;;
|
|
*) name="$1"; shift ;;
|
|
esac
|
|
done
|
|
resolve_env_for_read "$name"
|
|
|
|
if [ "$assume_yes" != 1 ]; then
|
|
printf 'Destroy %s? This drops database %s and profile %s. [y/N] ' "$NAME" "$DB_NAME" "$PROFILE"
|
|
read -r reply || reply=n
|
|
case "$reply" in y|Y|yes|YES) ;; *) printf 'Cancelled.\n'; return 0 ;; esac
|
|
fi
|
|
|
|
export PORT="$BACKEND_PORT" FRONTEND_PORT DATABASE_URL POSTGRES_DB="$DB_NAME"
|
|
step "Destroying $NAME"
|
|
local comp
|
|
for comp in $ALL_COMPONENTS; do
|
|
if ! stop_component "$comp"; then failures=$((failures + 1)); fi
|
|
done
|
|
|
|
if command -v psql >/dev/null 2>&1; then
|
|
admin_url="$(admin_database_url "$DATABASE_URL")"
|
|
if PGCONNECT_TIMEOUT=3 psql "$admin_url" -tAc 'SELECT 1' >/dev/null 2>&1; then
|
|
if psql "$admin_url" -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"$DB_NAME\" WITH (FORCE)" >/dev/null; then
|
|
ok "dropped database $DB_NAME"
|
|
else
|
|
warn "failed to drop database $DB_NAME; keeping its manifest"
|
|
failures=$((failures + 1))
|
|
fi
|
|
else
|
|
warn "Nothing answered on the database host; $DB_NAME was left in place."
|
|
failures=$((failures + 1))
|
|
fi
|
|
else
|
|
warn "psql not found; $DB_NAME was left in place."
|
|
failures=$((failures + 1))
|
|
fi
|
|
|
|
if rm -rf "$PROFILE_DIR"; then
|
|
ok "removed CLI profile $PROFILE"
|
|
else
|
|
warn "failed to remove CLI profile $PROFILE"
|
|
failures=$((failures + 1))
|
|
fi
|
|
|
|
expected_workspaces="$DEV_WORKSPACES_PARENT/multica_workspaces_$PROFILE"
|
|
if [ "$WORKSPACES_ROOT" != "$expected_workspaces" ]; then
|
|
warn "refusing to remove unexpected workspaces root $WORKSPACES_ROOT (expected $expected_workspaces)"
|
|
failures=$((failures + 1))
|
|
elif rm -rf "$WORKSPACES_ROOT"; then
|
|
ok "removed daemon workspaces $WORKSPACES_ROOT"
|
|
else
|
|
warn "failed to remove daemon workspaces $WORKSPACES_ROOT"
|
|
failures=$((failures + 1))
|
|
fi
|
|
|
|
expected_desktop_data="$(desktop_user_data_dir "$DESKTOP_APP_SUFFIX")"
|
|
if [ "$DESKTOP_USER_DATA_DIR" != "$expected_desktop_data" ]; then
|
|
warn "refusing to remove unexpected Desktop userData $DESKTOP_USER_DATA_DIR"
|
|
failures=$((failures + 1))
|
|
elif rm -rf "$DESKTOP_USER_DATA_DIR"; then
|
|
ok "removed Desktop userData $DESKTOP_USER_DATA_DIR"
|
|
else
|
|
warn "failed to remove Desktop userData $DESKTOP_USER_DATA_DIR"
|
|
failures=$((failures + 1))
|
|
fi
|
|
|
|
if [ -f "$DESKTOP_ENV_FILE" ] \
|
|
&& grep -Fqx "# Managed by scripts/dev-env.sh for environment ${NAME}." "$DESKTOP_ENV_FILE"; then
|
|
if rm -f "$DESKTOP_ENV_FILE"; then
|
|
ok "removed managed Desktop env file"
|
|
else
|
|
warn "failed to remove $DESKTOP_ENV_FILE"
|
|
failures=$((failures + 1))
|
|
fi
|
|
fi
|
|
|
|
if [ "$failures" -ne 0 ]; then
|
|
die "$NAME was only partially destroyed ($failures cleanup failure(s)). Its manifest and slot were kept so 'make destroy' can retry."
|
|
fi
|
|
|
|
rm -rf "$(env_dir "$NAME")"
|
|
ok "released slot $OFFSET"
|
|
printf '\n%s✓ %s destroyed.%s\n' "$C_GREEN" "$NAME" "$C_OFF"
|
|
}
|
|
|
|
cmd_status() {
|
|
local name="" as_json=0
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--json) as_json=1; shift ;;
|
|
-*) die "Unknown flag for status: $1" ;;
|
|
*) name="$1"; shift ;;
|
|
esac
|
|
done
|
|
resolve_env_for_read "$name"
|
|
if [ "$as_json" = 1 ]; then print_status_json; else print_status_human; fi
|
|
}
|
|
|
|
cmd_list() {
|
|
local as_json=0 name first=1 alive
|
|
[ "${1:-}" = "--json" ] && as_json=1
|
|
|
|
if [ "$as_json" = 1 ]; then printf '['; fi
|
|
local printed=0
|
|
while read -r name; do
|
|
[ -n "$name" ] || continue
|
|
(
|
|
load_manifest "$name"
|
|
bind_paths
|
|
alive="$(component_state api)"
|
|
alive="${alive%%|*}"
|
|
if [ "$as_json" = 1 ]; then
|
|
printf '{"name":"%s","dir":"%s","owner":"%s","api":"%s","backend_port":%s,"frontend_port":%s,"desktop_renderer_port":%s,"database":"%s","created_at":"%s","ttl_hours":%s,"expires_at":"%s"}' \
|
|
"$(json_escape "$NAME")" "$(json_escape "$DIR")" "$(json_escape "$OWNER")" "$alive" \
|
|
"$BACKEND_PORT" "$FRONTEND_PORT" "$DESKTOP_RENDERER_PORT" "$(json_escape "$DB_NAME")" "$(json_escape "$CREATED_AT")" "${TTL_HOURS:-0}" "$(json_escape "$EXPIRES_AT")"
|
|
else
|
|
printf '%-24s %-8s %-8s %-6s %-28s %s\n' "$NAME" "$alive" "$OWNER" \
|
|
"$BACKEND_PORT" "$DB_NAME" "$( [ -d "$DIR" ] && printf '%s' "$DIR" || printf '%s(directory gone)%s' "$C_RED" "$C_OFF" )"
|
|
fi
|
|
) | { if [ "$as_json" = 1 ] && [ "$printed" != 0 ]; then printf ','; fi; cat; }
|
|
printed=1
|
|
first=0
|
|
done <<EOF
|
|
$(list_env_names)
|
|
EOF
|
|
if [ "$as_json" = 1 ]; then
|
|
printf ']\n'
|
|
elif [ "$printed" = 0 ]; then
|
|
printf 'No environments registered. Run `make up`.\n'
|
|
fi
|
|
}
|
|
|
|
# Leaks become visible here rather than being discovered by counting databases
|
|
# in psql: an environment whose directory is gone, or whose TTL has passed, has
|
|
# no owner left to stop it.
|
|
cmd_gc() {
|
|
local dry_run=0 automatic=0 name age_hours created_epoch expiry_epoch reason
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--dry-run) dry_run=1 ;;
|
|
--auto) automatic=1 ;;
|
|
*) die "Unknown flag for gc: $1" ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
while read -r name; do
|
|
[ -n "$name" ] || continue
|
|
(
|
|
load_manifest "$name"
|
|
reason=""
|
|
[ -d "$DIR" ] || reason="its checkout directory is gone"
|
|
if [ -z "$reason" ] && [ "${TTL_HOURS:-0}" != 0 ]; then
|
|
if [ -n "${EXPIRES_AT:-}" ]; then
|
|
expiry_epoch="$(node -e 'process.stdout.write(String(Math.floor(Date.parse(process.argv[1]) / 1000)))' "$EXPIRES_AT" 2>/dev/null || echo 0)"
|
|
[ "$(now_epoch)" -lt "$expiry_epoch" ] || reason="it expired at $EXPIRES_AT"
|
|
else
|
|
created_epoch="$(node -e 'process.stdout.write(String(Math.floor(Date.parse(process.argv[1]) / 1000)))' "$CREATED_AT" 2>/dev/null || echo 0)"
|
|
age_hours=$(( ($(now_epoch) - created_epoch) / 3600 ))
|
|
[ "$age_hours" -lt "$TTL_HOURS" ] || reason="it expired ${age_hours}h after a ${TTL_HOURS}h ttl"
|
|
fi
|
|
fi
|
|
[ -n "$reason" ] || exit 0
|
|
if [ "$dry_run" = 1 ]; then
|
|
printf '%s would be collected: %s\n' "$NAME" "$reason"
|
|
else
|
|
printf '%s: %s\n' "$NAME" "$reason"
|
|
if ! bash "$REPO_ROOT/scripts/dev-env.sh" destroy "$NAME" --yes; then
|
|
if [ "$automatic" = 1 ]; then
|
|
warn "automatic cleanup of $NAME failed; its manifest was kept for retry"
|
|
else
|
|
exit 1
|
|
fi
|
|
fi
|
|
fi
|
|
)
|
|
done <<EOF
|
|
$(list_env_names)
|
|
EOF
|
|
}
|
|
|
|
# Runs a command with this environment's variables, without the agent runtime's
|
|
# production MULTICA_* values and with a durable TMPDIR. Replaces the prefix
|
|
# people used to have to copy out of a document by hand.
|
|
cmd_exec() {
|
|
local name=""
|
|
if [ "${1:-}" != "--" ] && [ $# -gt 0 ]; then name="$1"; shift; fi
|
|
[ "${1:-}" = "--" ] && shift
|
|
[ $# -gt 0 ] || die "Usage: dev-env.sh exec [name] -- <command> [args...]"
|
|
|
|
resolve_env_for_read "$name"
|
|
mkdir -p "$DEV_TMPDIR"
|
|
cd "$DIR"
|
|
load_env_file "$ENV_FILE" "$DIR"
|
|
export PORT="$BACKEND_PORT" FRONTEND_PORT DATABASE_URL POSTGRES_DB="$DB_NAME"
|
|
export TMPDIR="$DEV_TMPDIR" TMP="$DEV_TMPDIR" TEMP="$DEV_TMPDIR"
|
|
export MULTICA_DEV_PROFILE="$PROFILE"
|
|
exec "${CLEAN_ENV[@]}" MULTICA_WORKSPACES_ROOT="$WORKSPACES_ROOT" "$@"
|
|
}
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Local development environments: named, listable, deletable.
|
|
|
|
dev-env.sh up [--components api,web,daemon,desktop] [--all]
|
|
[--name N] [--ephemeral] [--ttl HOURS]
|
|
dev-env.sh status [name] [--json]
|
|
dev-env.sh list [--json]
|
|
dev-env.sh down [name] [--components ...]
|
|
dev-env.sh destroy [name] [--yes]
|
|
dev-env.sh gc [--dry-run]
|
|
dev-env.sh exec [name] -- <command> [args...]
|
|
|
|
Components: api (Go backend), web (Next.js), daemon (agent daemon),
|
|
desktop (Electron). Anything selected implies api.
|
|
|
|
down keeps the database, the CLI profile and the allocated slot.
|
|
destroy consumes them.
|
|
EOF
|
|
}
|
|
|
|
main() {
|
|
local verb="${1:-}"
|
|
[ $# -gt 0 ] && shift || true
|
|
case "$verb" in
|
|
up) cmd_up "$@" ;;
|
|
down) cmd_down "$@" ;;
|
|
status) cmd_status "$@" ;;
|
|
list|ls) cmd_list "$@" ;;
|
|
destroy) cmd_destroy "$@" ;;
|
|
gc) cmd_gc "$@" ;;
|
|
exec) cmd_exec "$@" ;;
|
|
""|-h|--help|help) usage ;;
|
|
*) usage >&2; exit 2 ;;
|
|
esac
|
|
}
|
|
|
|
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
|
main "$@"
|
|
fi
|