feat(webui): the Console as a user service (#1569, row 75)
scripts/webui-service.sh render|install|uninstall|status and the packages/webui/systemd/mosaic-webui.service.in template, on the bus-service.sh pattern. install writes the unit to a temp file, renames it and runs only `systemctl --user daemon-reload`. There is no start, stop, enable or restart verb: the Console reads the bus through the human transport, so Jason starts it from his own shell (lead decisions 62 and 94). Serve's flags pass through, including row 74's preview set, each held to a character set the unit can carry. The unit sets no MOSAIC_*, CLAUDE*, CODEX* or PI_* variable and does not restart on serve's exit 2. Tests in packages/webui/tests/service.test.mjs; TOOLS.md entry. Author: Rocko. Candidate manifest 7dc571dbe87a484f465c82e1965e499c57d9af047bfe702faa3fe041138dd154 (agents/rocko/work/queue-75/, base43d2328f, no drift on the manifest paths to8b351c3d). Approved by Filbert, comment 27430 on #1569, with five non-blocking notes placed in row 77. Sage gate rerun on a worktree of8b351c3dwith the patch: manifest OK; webui 50/50; test-auth 15, conductor 17, config 24, discord 66, extension-package 18, foundation 44, queue 27, release 14, task 98, all passed, 0 failed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Executable
+208
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env bash
|
||||
# `scripts/webui-service.sh render|install|uninstall|status`: the systemd
|
||||
# user unit that runs the Console, `node packages/webui/src/cli.mjs serve`
|
||||
# (lead decision 94). The template is
|
||||
# packages/webui/systemd/mosaic-webui.service.in; the rendered unit is
|
||||
# mosaic-webui.service in the systemd user directory, one instance.
|
||||
#
|
||||
# render [FLAGS] print the rendered unit on stdout
|
||||
# install [--dir DIR] [--no-reload] [FLAGS]
|
||||
# write the unit (write to a temp file, then
|
||||
# rename) and run `systemctl --user daemon-reload`
|
||||
# uninstall [--dir DIR] [--no-reload]
|
||||
# remove the unit; refuses while it is active
|
||||
# status [--dir DIR] unit state, then GET /healthz on its port
|
||||
#
|
||||
# FLAGS are serve's: --port N (default 7332; 7330 is the operator's own
|
||||
# Console), --business ID, --board URL, and the preview set --public-host
|
||||
# NAME --auth-header HEADER --allow-user ID, all three or none. systemd
|
||||
# expands % and $ and splits on spaces, so each value is held to a plain
|
||||
# character set and refused otherwise. None is a secret.
|
||||
#
|
||||
# There is no start, stop, enable or restart verb. The Console reads the
|
||||
# bus through the human transport, so whoever starts it holds the human's
|
||||
# capability. An agent starting it, even detached through systemd, works
|
||||
# around the human proof (lead decisions 62 and 94). Jason starts it from
|
||||
# his own shell: `systemctl --user enable --now mosaic-webui`.
|
||||
#
|
||||
# Exit codes: 0 ok, 1 operation failed, 4 usage.
|
||||
set -euo pipefail
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
|
||||
UNIT_NAME="mosaic-webui.service"
|
||||
USAGE="usage: scripts/webui-service.sh render [FLAGS] | install [--dir DIR] [--no-reload] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
|
||||
FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID]"
|
||||
|
||||
die() { echo "webui-service: $*" >&2; exit 1; }
|
||||
usage() { echo "$USAGE" >&2; exit 4; }
|
||||
|
||||
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
RELOAD=1
|
||||
DIR_FLAGS=0
|
||||
PORT=7332
|
||||
BUSINESS=""
|
||||
BOARD=""
|
||||
PUBLIC_HOST=""
|
||||
AUTH_HEADER=""
|
||||
ALLOW_USER=""
|
||||
SERVE_SET=0
|
||||
|
||||
parse_flags() {
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dir) [ $# -ge 2 ] || usage; UNIT_DIR="$2"; DIR_FLAGS=1; shift 2 ;;
|
||||
--no-reload) RELOAD=0; DIR_FLAGS=1; shift ;;
|
||||
--port) [ $# -ge 2 ] || usage; PORT="$2"; SERVE_SET=1; shift 2 ;;
|
||||
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; SERVE_SET=1; shift 2 ;;
|
||||
--board) [ $# -ge 2 ] || usage; BOARD="$2"; SERVE_SET=1; shift 2 ;;
|
||||
--public-host) [ $# -ge 2 ] || usage; PUBLIC_HOST="$2"; SERVE_SET=1; shift 2 ;;
|
||||
--auth-header) [ $# -ge 2 ] || usage; AUTH_HEADER="$2"; SERVE_SET=1; shift 2 ;;
|
||||
--allow-user) [ $# -ge 2 ] || usage; ALLOW_USER="$2"; SERVE_SET=1; shift 2 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# The serve flags as one ExecStart fragment. Each character set excludes
|
||||
# space, %, $, quotes, backslash, & and |, so neither systemd nor the sed
|
||||
# below can read a value as anything but itself.
|
||||
serve_flags() {
|
||||
case "$PORT" in ''|*[!0-9]*) die "port must be 1..65535: $PORT" ;; esac
|
||||
[ "${#PORT}" -le 5 ] && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] || die "port must be 1..65535: $PORT"
|
||||
local flags="--port $PORT"
|
||||
if [ -n "$BUSINESS" ]; then
|
||||
case "$BUSINESS" in [a-z0-9]*) ;; *) die "business must be a business id: $BUSINESS" ;; esac
|
||||
case "$BUSINESS" in *[!a-z0-9._-]*) die "business must be a business id: $BUSINESS" ;; esac
|
||||
[ "${#BUSINESS}" -le 64 ] || die "business must be a business id: $BUSINESS"
|
||||
flags="$flags --business $BUSINESS"
|
||||
fi
|
||||
if [ -n "$BOARD" ]; then
|
||||
# serve checks the rest (a loopback origin, no path or credentials).
|
||||
case "$BOARD" in http://?*) ;; *) die "board must be an http:// URL: $BOARD" ;; esac
|
||||
case "$BOARD" in *[!A-Za-z0-9.:/_-]*) die "board contains a character the unit cannot carry: $BOARD" ;; esac
|
||||
flags="$flags --board $BOARD"
|
||||
fi
|
||||
local preview=0
|
||||
[ -z "$PUBLIC_HOST" ] || preview=$((preview + 1))
|
||||
[ -z "$AUTH_HEADER" ] || preview=$((preview + 1))
|
||||
[ -z "$ALLOW_USER" ] || preview=$((preview + 1))
|
||||
if [ "$preview" -ne 0 ]; then
|
||||
[ "$preview" -eq 3 ] || die "--public-host, --auth-header and --allow-user go together: all three or none"
|
||||
case "$PUBLIC_HOST" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $PUBLIC_HOST" ;; esac
|
||||
case "$PUBLIC_HOST" in *[!A-Za-z0-9.-]*) die "public host must be a hostname, no port or scheme: $PUBLIC_HOST" ;; esac
|
||||
case "$AUTH_HEADER" in [A-Za-z0-9]*) ;; *) die "auth header must be a header name: $AUTH_HEADER" ;; esac
|
||||
case "$AUTH_HEADER" in *[!A-Za-z0-9-]*) die "auth header must be a header name: $AUTH_HEADER" ;; esac
|
||||
case "$ALLOW_USER" in *[!A-Za-z0-9._@+-]*) die "allowed user contains a character the unit cannot carry: $ALLOW_USER" ;; esac
|
||||
flags="$flags --public-host $PUBLIC_HOST --auth-header $AUTH_HEADER --allow-user $ALLOW_USER"
|
||||
fi
|
||||
echo "$flags"
|
||||
}
|
||||
|
||||
render() {
|
||||
[ -f "$TEMPLATE" ] || die "template missing: $TEMPLATE"
|
||||
local node
|
||||
node="$(command -v node || true)"
|
||||
[ -n "$node" ] || die "node not found on PATH"
|
||||
# systemd does not look an ExecStart command up in Environment=PATH,
|
||||
# so the unit runs node by the path found here, kept as given.
|
||||
case "$node" in /*) ;; *) die "node path must be absolute: $node" ;; esac
|
||||
case "$node" in *[!A-Za-z0-9/._+-]*) die "node path contains a character the unit cannot carry: $node" ;; esac
|
||||
case "$REPO" in *[!A-Za-z0-9/._+-]*) die "repository path contains a character the unit cannot carry: $REPO" ;; esac
|
||||
local node_dir path="/usr/local/bin:/usr/bin:/bin"
|
||||
node_dir="$(dirname "$node")"
|
||||
case ":$path:" in *":$node_dir:"*) ;; *) path="$node_dir:$path" ;; esac
|
||||
local flags
|
||||
flags="$(serve_flags)" || exit 1
|
||||
sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" -e "s|@NODE@|$node|g" -e "s|@FLAGS@|$flags|g" "$TEMPLATE"
|
||||
}
|
||||
|
||||
install_unit() {
|
||||
parse_flags "$@"
|
||||
local unit
|
||||
unit="$(render)" || exit 1
|
||||
mkdir -p "$UNIT_DIR"
|
||||
local target="$UNIT_DIR/$UNIT_NAME" tmp
|
||||
tmp="$(mktemp "$UNIT_DIR/.$UNIT_NAME.XXXXXX")"
|
||||
printf '%s\n' "$unit" > "$tmp"
|
||||
chmod 0644 "$tmp"
|
||||
if [ -f "$target" ] && cmp -s "$tmp" "$target"; then
|
||||
rm -f "$tmp"
|
||||
echo "unchanged: $target"
|
||||
else
|
||||
mv -f "$tmp" "$target"
|
||||
echo "written: $target"
|
||||
fi
|
||||
echo "serve: $(sed -n 's/^ExecStart=//p' "$target")"
|
||||
if [ "$RELOAD" = 1 ]; then
|
||||
systemctl --user daemon-reload || die "daemon-reload failed"
|
||||
echo "daemon-reload done"
|
||||
fi
|
||||
cat <<MSG
|
||||
this script starts nothing: the Console holds the human's capability, so the
|
||||
human starts it from their own shell:
|
||||
systemctl --user enable --now mosaic-webui start now and at login
|
||||
systemctl --user status mosaic-webui
|
||||
journalctl --user -u mosaic-webui -f the Console's log
|
||||
systemctl --user stop mosaic-webui SIGTERM; restartable
|
||||
scripts/webui-service.sh status unit state and /healthz
|
||||
survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
|
||||
MSG
|
||||
}
|
||||
|
||||
uninstall_unit() {
|
||||
parse_flags "$@"
|
||||
[ "$SERVE_SET" = 0 ] || usage
|
||||
local target="$UNIT_DIR/$UNIT_NAME"
|
||||
if [ "$RELOAD" = 1 ]; then
|
||||
local active
|
||||
active="$(systemctl --user list-units --no-legend --plain "$UNIT_NAME" 2>/dev/null | awk '$3 == "active" || $3 == "activating" || $3 == "deactivating" {print $1}')"
|
||||
[ -z "$active" ] || die "refusing: $UNIT_NAME is still running; the human stops it first (systemctl --user stop mosaic-webui)"
|
||||
fi
|
||||
if [ -f "$target" ]; then
|
||||
rm -f "$target"
|
||||
echo "removed: $target"
|
||||
else
|
||||
echo "absent: $target"
|
||||
fi
|
||||
if [ "$RELOAD" = 1 ]; then
|
||||
systemctl --user daemon-reload || die "daemon-reload failed"
|
||||
echo "daemon-reload done"
|
||||
fi
|
||||
}
|
||||
|
||||
status_unit() {
|
||||
parse_flags "$@"
|
||||
[ "$SERVE_SET" = 0 ] && [ "$RELOAD" = 1 ] || usage
|
||||
local target="$UNIT_DIR/$UNIT_NAME"
|
||||
echo "unit: $UNIT_NAME"
|
||||
echo " enabled: $(systemctl --user is-enabled "$UNIT_NAME" 2>&1 || true)"
|
||||
echo " active: $(systemctl --user is-active "$UNIT_NAME" 2>&1 || true)"
|
||||
systemctl --user show "$UNIT_NAME" -p MainPID -p NRestarts -p ExecMainStartTimestamp -p Result 2>/dev/null | sed 's/^/ /' || true
|
||||
if [ ! -f "$target" ]; then
|
||||
echo "healthz: not checked; no unit at $target"
|
||||
return 0
|
||||
fi
|
||||
local port
|
||||
port="$(sed -n 's/^ExecStart=.* --port \([0-9][0-9]*\).*$/\1/p' "$target")"
|
||||
[ -n "$port" ] || { echo "healthz: not checked; no --port in $target"; return 0; }
|
||||
local url="http://127.0.0.1:$port/healthz"
|
||||
echo "healthz: $url $(node -e '
|
||||
fetch(process.argv[1], { signal: AbortSignal.timeout(5000) })
|
||||
.then(async (res) => console.log(`${res.status} ${(await res.text()).trim()}`))
|
||||
.catch((err) => console.log(`unreachable (${err.cause?.code ?? err.name})`));
|
||||
' "$url")"
|
||||
}
|
||||
|
||||
[ $# -ge 1 ] || usage
|
||||
cmd="$1"; shift
|
||||
case "$cmd" in
|
||||
render)
|
||||
parse_flags "$@"
|
||||
[ "$DIR_FLAGS" = 0 ] || usage
|
||||
render ;;
|
||||
install) install_unit "$@" ;;
|
||||
uninstall) uninstall_unit "$@" ;;
|
||||
status) status_unit "$@" ;;
|
||||
-h|--help) echo "$USAGE" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
Reference in New Issue
Block a user