#!/usr/bin/env bash # # OpenAgents Control — Architect Fork · web UI service installer # ============================================================================ # Creates (or removes) a systemd service that runs the OpenCode web UI. # # opencode serve --hostname --port # # Optionally protects it with OpenCode's own HTTP basic auth: the password is # written to a root-readable env file (mode 600) referenced by the unit via # EnvironmentFile= — the password never appears in the unit itself. # # Usage: # ./install-service.sh # interactive (asks for a password) # ./install-service.sh --port 4096 # ./install-service.sh --host 0.0.0.0 --port 8080 # ./install-service.sh --yes # non-interactive defaults # ./install-service.sh --password secret # enable basic auth # ./install-service.sh --username admin --password secret # ./install-service.sh --no-password # ensure the server is unsecured # ./install-service.sh --uninstall # stop and remove the service # ./install-service.sh --dry-run # show the unit, change nothing # # Environment: # OPENCODE_SERVICE_NAME unit name (default: opencode.service) # OPENCODE_SERVER_PASSWORD basic-auth password (same as --password) # OPENCODE_SERVER_USERNAME basic-auth username (default: opencode) # OPENCODE_ENV_FILE env file path (default: /etc/opencode/opencode.env) # OAC_FORCE_SYSTEMD 1/0 to force systemd detection (containers/CI) # ============================================================================ set -euo pipefail SERVICE_NAME="${OPENCODE_SERVICE_NAME:-opencode.service}" DEFAULT_HOST="127.0.0.1" DEFAULT_PORT="4096" OPENCODE_BIN_DIR="${HOME}/.opencode/bin" # --------------------------------------------------------------------------- # Output helpers # --------------------------------------------------------------------------- if [ -t 1 ]; then RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' BLUE='\033[0;34m'; CYAN='\033[0;36m'; MAGENTA='\033[0;35m' BOLD='\033[1m'; NC='\033[0m' else RED=''; GREEN=''; YELLOW=''; BLUE=''; CYAN=''; MAGENTA=''; BOLD=''; NC='' fi info() { printf "${BLUE}ℹ${NC} %s\n" "$1"; } success() { printf "${GREEN}✓${NC} %s\n" "$1"; } warn() { printf "${YELLOW}⚠${NC} %s\n" "$1"; } error() { printf "${RED}✗${NC} %s\n" "$1" >&2; } step() { printf "\n${MAGENTA}${BOLD}▶${NC} %s\n\n" "$1"; } die() { error "$1"; exit 1; } # Prompt on the controlling terminal — works even under `curl … | bash`. have_tty() { [ -t 1 ] || return 1 [ -t 0 ] && return 0 [ -r /dev/tty ] } tty_read() { local __var="$1" __val="" if [ -t 0 ] || [ ! -r /dev/tty ]; then read -r __val || true else read -r __val /dev/null 2>&1 && return 0 [ -x "${OPENCODE_BIN_DIR}/opencode" ] && return 0 return 1 } opencode_path() { if command -v opencode >/dev/null 2>&1; then command -v opencode else printf '%s' "${OPENCODE_BIN_DIR}/opencode"; fi } is_systemd() { if [ "${OAC_FORCE_SYSTEMD:-}" = "1" ]; then return 0; fi if [ "${OAC_FORCE_SYSTEMD:-}" = "0" ]; then return 1; fi command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ] } # --------------------------------------------------------------------------- # Arguments # --------------------------------------------------------------------------- HOST=""; PORT=""; WORKDIR=""; SVC_USER=""; SVC_GROUP="" ENABLE="" # 1 | 0 | empty=ask CORS="" # space-separated extra allowed origins SVC_USERNAME="${OPENCODE_SERVER_USERNAME:-opencode}" PASSWORD="${OPENCODE_SERVER_PASSWORD:-}" PASSWORD_SET=0 [ -n "$PASSWORD" ] && PASSWORD_SET=1 ENV_FILE="${OPENCODE_ENV_FILE:-/etc/opencode/opencode.env}" ASSUME_YES=0 DRY_RUN=0 UNINSTALL=0 usage() { cat <<'EOF' OpenAgents Control — Architect Fork · web UI service installer Creates (or removes) a systemd service that runs the OpenCode web UI (opencode serve). Optionally protects it with HTTP basic auth. Usage: install-service.sh [OPTIONS] Options: --host HOST bind address (default: 127.0.0.1) --port PORT port (default: 4096) --dir PATH working directory (default: current dir) --user USER run the service as USER (default: you) --enable start and enable on boot --no-enable install only; do not start --cors ORIGIN allow this origin (repeatable); use if the web UI is served from a different origin than the server --password PW enable basic auth with this password --username USER basic-auth username (default: opencode) --no-password do not set a password (server unsecured) --env-file PATH where to store credentials (default: /etc/opencode/opencode.env) -y, --yes non-interactive (defaults; no prompts) --uninstall stop, disable and remove the service --dry-run show the unit and exit without changing anything -h, --help show this help Environment: OPENCODE_SERVICE_NAME, OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_USERNAME, OPENCODE_ENV_FILE, OAC_FORCE_SYSTEMD EOF } while [ $# -gt 0 ]; do case "$1" in --host) [ $# -ge 2 ] || die "--host needs a value"; HOST="$2"; shift ;; --host=*) HOST="${1#*=}" ;; --port) [ $# -ge 2 ] || die "--port needs a value"; PORT="$2"; shift ;; --port=*) PORT="${1#*=}" ;; --dir) [ $# -ge 2 ] || die "--dir needs a value"; WORKDIR="$2"; shift ;; --dir=*) WORKDIR="${1#*=}" ;; --user) [ $# -ge 2 ] || die "--user needs a value"; SVC_USER="$2"; shift ;; --user=*) SVC_USER="${1#*=}" ;; --username) [ $# -ge 2 ] || die "--username needs a value"; SVC_USERNAME="$2"; shift ;; --username=*) SVC_USERNAME="${1#*=}" ;; --password) [ $# -ge 2 ] || die "--password needs a value"; PASSWORD="$2"; PASSWORD_SET=1; shift ;; --password=*) PASSWORD="${1#*=}"; PASSWORD_SET=1 ;; --no-password) PASSWORD=""; PASSWORD_SET=0 ;; --env-file) [ $# -ge 2 ] || die "--env-file needs a value"; ENV_FILE="$2"; shift ;; --env-file=*) ENV_FILE="${1#*=}" ;; --enable) ENABLE=1 ;; --no-enable) ENABLE=0 ;; --cors) [ $# -ge 2 ] || die "--cors needs an origin"; CORS="${CORS} $2"; shift ;; --cors=*) CORS="${CORS} ${1#*=}" ;; -y|--yes) ASSUME_YES=1 ;; --uninstall) UNINSTALL=1 ;; --dry-run) DRY_RUN=1 ;; -h|--help) usage; exit 0 ;; *) error "Unknown option: $1"; echo; usage; exit 1 ;; esac shift done # --------------------------------------------------------------------------- # Privilege # --------------------------------------------------------------------------- SUDO="" [ "$(id -u)" != "0" ] && SUDO="sudo" UNIT_PATH="/etc/systemd/system/${SERVICE_NAME}" # --------------------------------------------------------------------------- # Uninstall # --------------------------------------------------------------------------- if [ "$UNINSTALL" = 1 ]; then if ! is_systemd; then warn "systemd not detected — nothing to uninstall here."; exit 0 fi if [ "$DRY_RUN" = 1 ]; then info "[dry-run] would stop, disable and delete ${UNIT_PATH}"; exit 0 fi $SUDO systemctl disable --now "$SERVICE_NAME" 2>/dev/null || true if [ -e "$UNIT_PATH" ]; then $SUDO rm -f "$UNIT_PATH" && success "Removed ${UNIT_PATH}" else info "No unit at ${UNIT_PATH}" fi $SUDO systemctl daemon-reload 2>/dev/null || true info "Credentials file left in place: ${ENV_FILE} (remove it manually if you like)." exit 0 fi # --------------------------------------------------------------------------- # Locate OpenCode # --------------------------------------------------------------------------- have_opencode || die "OpenCode is not installed (looked on PATH and ${OPENCODE_BIN_DIR}). Install it first." OC_BIN="$(opencode_path)" # OpenCode v2 manages its own background server (`opencode service`) and runs it # as `opencode serve --service`; the server reads host/port/password/cors from the # service config. v1 has no manager — we pass flags + an env file instead. OPENCODE_V2=0 if "$OC_BIN" serve --help 2>&1 | grep -q -- '--service'; then OPENCODE_V2=1; fi # Run an opencode command as the service user. oc_run() { if [ "$SVC_USER" = "$(id -un)" ]; then "$OC_BIN" "$@" else $SUDO -u "$SVC_USER" "$OC_BIN" "$@"; fi } # --------------------------------------------------------------------------- # Prompt (interactive only) # --------------------------------------------------------------------------- INTERACTIVE=0 if [ "$ASSUME_YES" = 0 ] && have_tty; then INTERACTIVE=1; fi if [ "$INTERACTIVE" = 1 ] && [ "$DRY_RUN" = 0 ]; then printf "\n${BOLD}Set up the OpenCode web UI as a systemd service?${NC} [Y/n]: " ans=""; tty_read ans case "$ans" in [Nn]*) info "Cancelled."; exit 0 ;; esac fi [ -n "$HOST" ] || HOST="$DEFAULT_HOST" [ -n "$PORT" ] || PORT="$DEFAULT_PORT" [ -n "$WORKDIR" ] || WORKDIR="$(pwd)" [ -n "$SVC_USER" ] || SVC_USER="$(id -un)" [ -n "$SVC_GROUP" ] || SVC_GROUP="$(id -gn)" if [ "$INTERACTIVE" = 1 ] && [ "$DRY_RUN" = 0 ]; then v="" printf " Bind address [%s]: " "$HOST"; tty_read v; [ -n "$v" ] && HOST="$v" printf " Port [%s]: " "$PORT"; tty_read v; [ -n "$v" ] && PORT="$v" printf " Working directory [%s]: " "$WORKDIR"; tty_read v; [ -n "$v" ] && WORKDIR="$v" printf " Start now and enable on boot? [Y/n]: "; v=""; tty_read v case "$v" in [Nn]*) ENABLE=0 ;; *) ENABLE=1 ;; esac printf " Extra allowed origins (CORS), space-separated [none]: "; v=""; tty_read v [ -n "$v" ] && CORS="${CORS} $v" if [ "$PASSWORD_SET" = 0 ]; then printf " Protect the server with a password? [y/N]: "; v=""; tty_read v case "$v" in [Yy]*) pw1=""; printf " Password: "; tty_read_secret pw1 pw2=""; printf " Confirm password: "; tty_read_secret pw2 if [ -z "$pw1" ]; then warn "Empty password — the server will be unsecured." elif [ "$pw1" != "$pw2" ]; then warn "Passwords do not match — the server will be unsecured." else PASSWORD="$pw1"; PASSWORD_SET=1 fi ;; esac fi fi [ -n "$ENABLE" ] || ENABLE=1 # --------------------------------------------------------------------------- # Validate the password (env-file safety) # --------------------------------------------------------------------------- if [ "$PASSWORD_SET" = 1 ]; then case "$PASSWORD" in *$'\n'*) warn "Password contains a newline — disabling auth."; PASSWORD_SET=0 ;; esac case "$SVC_USERNAME" in *$'\n'*) SVC_USERNAME="opencode" ;; esac if [ "$SVC_USERNAME" != "opencode" ]; then warn "Username '${SVC_USERNAME}' set — the OpenCode web UI always authenticates as 'opencode', so the browser login will fail unless you keep username 'opencode'." fi fi # --------------------------------------------------------------------------- # Build the unit # --------------------------------------------------------------------------- ENV_LINE="" EXEC_ARGS="" RESTART_POLICY="on-failure" if [ "$OPENCODE_V2" = 1 ]; then # v2: host/port/password/cors live in the service config, so run in service mode. EXEC_ARGS="--service" RESTART_POLICY="always" # a config change stops+restarts the server; bring it back else [ "$PASSWORD_SET" = 1 ] && ENV_LINE="EnvironmentFile=-${ENV_FILE}" for o in $CORS; do EXEC_ARGS="${EXEC_ARGS} --cors ${o}"; done EXEC_ARGS="--hostname ${HOST} --port ${PORT}${EXEC_ARGS}" fi UNIT="$(mktemp 2>/dev/null || mktemp -t oac-unit)" cat > "$UNIT" <')" else [ "$PASSWORD_SET" = 1 ] && info "[dry-run] would write credentials to ${ENV_FILE} (mode 600)" fi warn "Dry run — nothing was written." exit 0 fi if [ "$INTERACTIVE" = 1 ]; then printf "Install to %s? [y/N]: " "$UNIT_PATH" ans=""; tty_read ans case "$ans" in [Yy]*) ;; *) info "Cancelled."; exit 0 ;; esac fi # Apply the server configuration. if [ "$OPENCODE_V2" = 1 ]; then info "Configuring the OpenCode service…" oc_run service set hostname "$HOST" || die "opencode service set hostname failed" oc_run service set port "$PORT" || die "opencode service set port failed" if [ "$PASSWORD_SET" = 1 ]; then oc_run service set password "$PASSWORD" || die "opencode service set password failed" fi if [ -n "$CORS" ]; then cors_list="$(printf '%s' "$CORS" | tr ' ' ',')" oc_run service set cors "$cors_list" || die "opencode service set cors failed" fi success "Configured the OpenCode service (host/port/password/cors)" else if [ "$PASSWORD_SET" = 1 ]; then esc="$(printf '%s' "$PASSWORD" | sed 's/\\/\\\\/g; s/"/\\"/g')" if ! $SUDO mkdir -p "$(dirname "$ENV_FILE")"; then die "Could not create $(dirname "$ENV_FILE")" fi if ! { printf 'OPENCODE_SERVER_PASSWORD="%s"\n' "$esc" if [ "$SVC_USERNAME" != "opencode" ]; then printf 'OPENCODE_SERVER_USERNAME="%s"\n' "$SVC_USERNAME" fi } | $SUDO tee "$ENV_FILE" >/dev/null; then die "Could not write ${ENV_FILE}" fi $SUDO chmod 600 "$ENV_FILE" success "Wrote credentials to ${ENV_FILE} (mode 600)" fi fi # Back up an existing unit before replacing it if [ -e "$UNIT_PATH" ]; then backup="${UNIT_PATH}.bak.$(date +%Y%m%d-%H%M%S)" if $SUDO cp "$UNIT_PATH" "$backup" 2>/dev/null; then info "Backed up the existing unit to ${backup}" fi fi if ! $SUDO cp "$UNIT" "$UNIT_PATH"; then die "Could not write ${UNIT_PATH} (needs root). Re-run with sudo." fi $SUDO systemctl daemon-reload 2>/dev/null || true if [ "$ENABLE" = 1 ]; then if $SUDO systemctl enable --now "$SERVICE_NAME" 2>/dev/null; then success "Web UI service enabled — http://${HOST}:${PORT}" else warn "Unit installed; enable it later: sudo systemctl enable --now ${SERVICE_NAME}" fi else success "Unit installed — start it with: sudo systemctl start ${SERVICE_NAME}" fi if [ "$PASSWORD_SET" = 1 ]; then if [ "$OPENCODE_V2" = 1 ]; then info "Auth: HTTP basic — username 'opencode', password in the OpenCode service config" info "In the web app: Add server → address = your public URL, username blank (opencode), password." else info "Auth: HTTP basic — username '${SVC_USERNAME}', password from ${ENV_FILE}" fi else warn "No password set — the server is unsecured. Put it behind your proxy's auth." fi