#!/usr/bin/env bash # `scripts/bus-service.sh render|install|uninstall|status []`: # the systemd user unit that supervises `scripts/mosaic bus start ` # (lead decision 70). The template is # packages/cli/systemd/mosaic-bus.service.in; the rendered unit is # mosaic-bus@.service in the systemd user directory, one instance per # business (mosaic-bus@). Nothing here reads a capability, the # binding or the token. See packages/cli/README.md, "Bus host". # # render [--claude PATH] print the rendered unit on stdout # install [--dir DIR] [--no-reload] [--claude PATH] # 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 an instance is active # status unit state, then `mosaic bus status` # # --claude PATH pins the Claude Code binary the unit's sessions run # (MOSAIC_CLAUDE_BIN); without it, `command -v claude` here. The path is # kept as given, so a symlink that tracks updates keeps tracking them. # # Exit codes: 0 ok, 1 operation failed, 4 usage. set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TEMPLATE="$REPO/packages/cli/systemd/mosaic-bus.service.in" UNIT_NAME="mosaic-bus@.service" USAGE="usage: scripts/bus-service.sh render [--copy ID [--copies DIR]] [--pm] [--claude PATH] | install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH] | uninstall [--dir DIR] [--no-reload] | status " die() { echo "bus-service: $*" >&2; exit 1; } usage() { echo "$USAGE" >&2; exit 4; } CLAUDE_BIN="" CLAUDE_SET=0 COPY_ID="" COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies" PM=0 # The unit's MOSAIC_CLAUDE_BIN line, or a comment when there is no claude to # pin. systemd expands % and splits Environment= on spaces, so the path is # held to a plain character set. claude_line() { local bin if [ "$CLAUDE_SET" = 1 ]; then bin="$CLAUDE_BIN" else bin="$(command -v claude || true)" if [ -z "$bin" ]; then echo "bus-service: no claude on PATH; MOSAIC_CLAUDE_BIN left unset (--claude PATH pins one)" >&2 echo "# MOSAIC_CLAUDE_BIN unset: no claude on PATH at install time" return 0 fi fi case "$bin" in /*) ;; *) die "claude path must be absolute: $bin" ;; esac case "$bin" in *[!A-Za-z0-9/._+-]*) die "claude path contains a character the unit cannot carry: $bin" ;; esac [ -f "$bin" ] && [ -x "$bin" ] || die "claude path is not an executable file: $bin" echo "Environment=MOSAIC_CLAUDE_BIN=$bin" } render() { [ -f "$TEMPLATE" ] || die "template missing: $TEMPLATE" local root="$REPO" if [ -n "$COPY_ID" ]; then [[ "$COPY_ID" =~ ^[0-9a-f]{40}$ ]] || die "--copy must be a full 40-character commit id" case "$COPIES" in /*) ;; *) die "copies directory must be absolute" ;; esac case "$COPIES" in *[!A-Za-z0-9/._+-]*) die "copies directory contains a character the unit cannot carry" ;; esac root="$COPIES/$COPY_ID" [ -f "$root/scripts/mosaic" ] || die "no copy $COPY_ID in $COPIES" fi local pm_flags="" [ "$PM" = 0 ] || pm_flags=" --pm" local node_dir node_dir="$(dirname "$(command -v node || true)")" [ -n "$node_dir" ] && [ "$node_dir" != "." ] || die "node not found on PATH" local path="/usr/local/bin:/usr/bin:/bin" case ":$path:" in *":$node_dir:"*) ;; *) path="$node_dir:$path" ;; esac case "$REPO" in *@*|*'|'*) die "repository path contains a character the template cannot carry: $REPO" ;; esac local claude claude="$(claude_line)" || exit 1 sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@PM_FLAGS@|$pm_flags|g" -e "s|^@CLAUDE_ENV@$|$claude|" "$TEMPLATE" } UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" RELOAD=1 parse_flags() { while [ $# -gt 0 ]; do case "$1" in --dir) [ $# -ge 2 ] || usage; UNIT_DIR="$2"; shift 2 ;; --no-reload) RELOAD=0; shift ;; --copy) [ $# -ge 2 ] || usage; COPY_ID="$2"; shift 2 ;; --copies) [ $# -ge 2 ] || usage; COPIES="$2"; shift 2 ;; --pm) PM=1; shift ;; --claude) [ $# -ge 2 ] || usage; CLAUDE_BIN="$2"; CLAUDE_SET=1; shift 2 ;; *) usage ;; esac done } # Inspect effective unit metadata: drop-ins can override the installed template. # Any failed read refuses before a write, including --no-reload installations. install_guard() { local out unit file out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl --user list-units failed, so the bus state is unknown; nothing was changed" while read -r unit load state rest; do [[ "$unit" == mosaic-bus@*.service ]] || continue case "$state" in inactive|failed) ;; *) die "refusing: $unit is running ($state); Jason or Mos stops it first, then install, then start it" ;; esac done <<< "$out" # systemctl show rejects a template name. The lookup-path scan also # covers stopped/unloaded instances that list-units does not enumerate. local paths directory dropdir content paths="$(systemd-analyze --user unit-paths 2>/dev/null)" || die "refusing: systemd-analyze --user unit-paths failed; drop-in paths are unknown; nothing was changed" [ -n "$paths" ] || die "refusing: systemd unit paths are unknown; nothing was changed" while IFS= read -r directory; do [ -n "$directory" ] || continue case "$directory" in /*) ;; *) die "refusing: invalid systemd unit path $directory; nothing was changed" ;; esac [ -e "$directory" ] || [ -L "$directory" ] || continue [ -d "$directory" ] && [ -r "$directory" ] && [ -x "$directory" ] || die "refusing: cannot inspect unit path $directory; nothing was changed" # * includes the empty instance, i.e. mosaic-bus@.service.d. for dropdir in "$directory"/mosaic-bus@*.service.d; do [ -e "$dropdir" ] || [ -L "$dropdir" ] || continue [ -d "$dropdir" ] && [ -r "$dropdir" ] && [ -x "$dropdir" ] || die "refusing: cannot inspect drop-ins $dropdir; nothing was changed" for file in "$dropdir"/*.conf; do [ -e "$file" ] || [ -L "$file" ] || continue content="$(cat "$file" 2>/dev/null)" || die "refusing: cannot read drop-in $file; nothing was changed" if grep -Eq '^[[:space:]]*(ExecStart|WorkingDirectory)[[:space:]]*=' <<< "$content"; then die "refusing: drop-in $file overrides ExecStart or WorkingDirectory; Mos removes it first and reloads systemd" fi done done done <<< "$paths" } install_unit() { parse_flags "$@" install_guard mkdir -p "$UNIT_DIR" local target="$UNIT_DIR/$UNIT_NAME" tmp unit unit="$(render)" || exit 1 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 local bin bin="$(sed -n 's/^Environment=MOSAIC_CLAUDE_BIN=//p' "$target")" if [ -n "$bin" ]; then echo "claude: $bin ($("$bin" --version 2>/dev/null | head -n 1 || true))" else echo "claude: MOSAIC_CLAUDE_BIN unset; sessions run claude from the unit's PATH" fi if [ "$RELOAD" = 1 ]; then systemctl --user daemon-reload || die "daemon-reload failed" echo "daemon-reload done" fi cat </notify/ the notifier refuses a looser directory write /notify//notify.json, mode 0600: {"notifyVersion": 1, "binding": ""} or "binding": null for no DMs systemctl --user enable --now mosaic-bus@ start now and at login systemctl --user status mosaic-bus@ journalctl --user -u mosaic-bus@ -f the host's log systemctl --user stop mosaic-bus@ SIGTERM; restartable survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)} MSG } uninstall_unit() { parse_flags "$@" [ "$CLAUDE_SET" = 0 ] && [ -z "$COPY_ID" ] && [ "$PM" = 0 ] || usage local target="$UNIT_DIR/$UNIT_NAME" local out active out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl read failed; nothing was changed" active="$(awk '$3 != "inactive" && $3 != "failed" {print $1}' <<< "$out")" [ -z "$active" ] || die "refusing: instance(s) still running: $active; Jason or Mos stops them first" 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() { [ $# -eq 1 ] || usage local business="$1" case "$business" in ''|*[!a-z0-9-]*) die "business id must be [a-z0-9-]+" ;; esac local unit="mosaic-bus@$business.service" echo "unit: $unit" echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)" echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)" systemctl --user show "$unit" -p MainPID -p NRestarts -p ExecMainStartTimestamp -p Result -p Environment 2>/dev/null | sed 's/^/ /' local effective root exec_start drops copy=checkout effective="$(systemctl --user show "$unit" -p LoadState -p WorkingDirectory -p ExecStart -p DropInPaths 2>/dev/null)" || die "cannot read effective unit $unit" root="$(sed -n 's/^WorkingDirectory=//p' <<< "$effective")" exec_start="$(sed -n 's/^ExecStart=//p' <<< "$effective")" drops="$(sed -n 's/^DropInPaths=//p' <<< "$effective")" if [[ "${root##*/}" =~ ^[0-9a-f]{40}$ ]] && [ -f "$root/scripts/mosaic" ];then copy="${root##*/}";fi echo "tree: ${root:-absent}" echo "copy: $copy" if [[ "$exec_start" =~ [[:space:]]--pm([[:space:];]|$) ]];then echo 'pm: on';else echo 'pm: off';fi echo "drop-ins: ${drops:-none}" local head head="$(git -C "$REPO" rev-parse refs/heads/refactor 2>/dev/null || true)" if [ "$copy" != checkout ] && [ "$head" != "$copy" ];then echo "refactor: ${head:-unknown} (another commit)";else echo "refactor: ${head:-unknown}";fi "$REPO/scripts/mosaic" bus status | sed 's/^/ /' || true } [ $# -ge 1 ] || usage cmd="$1"; shift case "$cmd" in render) while [ $# -gt 0 ]; do case "$1" in --copy) [ $# -ge 2 ] || usage; COPY_ID="$2"; shift 2 ;; --copies) [ $# -ge 2 ] || usage; COPIES="$2"; shift 2 ;; --pm) PM=1; shift ;; --claude) [ $# -ge 2 ] || usage; CLAUDE_BIN="$2"; CLAUDE_SET=1; shift 2 ;; *) usage ;; esac done render ;; install) install_unit "$@" ;; uninstall) uninstall_unit "$@" ;; status) status_unit "$@" ;; -h|--help) echo "$USAGE" ;; *) usage ;; esac