diff --git a/scripts/codex-acp-service-common.sh b/scripts/codex-acp-service-common.sh new file mode 100755 index 00000000..b1ce9046 --- /dev/null +++ b/scripts/codex-acp-service-common.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +set -euo pipefail + +XWORKMATE_CODEX_ACP_DEFAULT_HOST="${XWORKMATE_CODEX_ACP_DEFAULT_HOST:-127.0.0.1}" +XWORKMATE_CODEX_ACP_DEFAULT_PORT="${XWORKMATE_CODEX_ACP_DEFAULT_PORT:-9001}" +XWORKMATE_CODEX_ACP_PORT_SCAN_LIMIT="${XWORKMATE_CODEX_ACP_PORT_SCAN_LIMIT:-100}" +XWORKMATE_CODEX_ACP_DRY_RUN="${XWORKMATE_CODEX_ACP_DRY_RUN:-0}" + +common_die() { + echo "Error: $*" >&2 + exit 1 +} + +common_info() { + echo "$*" +} + +common_warn() { + echo "Warning: $*" >&2 +} + +common_print_cmd() { + printf '+' + printf ' %q' "$@" + printf '\n' +} + +common_run_service_cmd() { + if [[ "$XWORKMATE_CODEX_ACP_DRY_RUN" == "1" ]]; then + common_print_cmd "$@" + return 0 + fi + "$@" +} + +common_run_service_cmd_allow_fail() { + if [[ "$XWORKMATE_CODEX_ACP_DRY_RUN" == "1" ]]; then + common_print_cmd "$@" + return 0 + fi + "$@" || true +} + +common_validate_port() { + local port="$1" + if [[ ! "$port" =~ ^[0-9]+$ ]]; then + common_die "Invalid port: $port" + fi + if (( port < 1 || port > 65535 )); then + common_die "Port out of range: $port" + fi +} + +common_resolve_codex_bin() { + local candidate="${CODEX_BIN:-${XWORKMATE_CODEX_ACP_CODEX_BIN:-}}" + if [[ -z "$candidate" ]]; then + candidate="$(command -v codex || true)" + fi + if [[ -z "$candidate" ]]; then + common_die "Unable to find codex in PATH. Set CODEX_BIN=/absolute/path/to/codex." + fi + if [[ ! -x "$candidate" ]]; then + common_die "codex binary is not executable: $candidate" + fi + printf '%s\n' "$candidate" +} + +common_listen_url() { + local port="$1" + printf 'ws://%s:%s\n' "$XWORKMATE_CODEX_ACP_DEFAULT_HOST" "$port" +} + +common_load_config() { + if [[ -f "$CONFIG_FILE" ]]; then + # shellcheck disable=SC1090 + source "$CONFIG_FILE" + fi +} + +common_ensure_dirs() { + mkdir -p "$CONFIG_DIR" "$RUNTIME_DIR" + if [[ -n "${LOG_DIR:-}" ]]; then + mkdir -p "$LOG_DIR" + fi +} + +common_write_config() { + local codex_bin="$1" + local port="$2" + local listen_url + listen_url="$(common_listen_url "$port")" + + common_ensure_dirs + + { + printf 'CODEX_BIN=%q\n' "$codex_bin" + printf 'CODEX_ACP_HOST=%q\n' "$XWORKMATE_CODEX_ACP_DEFAULT_HOST" + printf 'CODEX_ACP_PORT=%q\n' "$port" + printf 'CODEX_ACP_LISTEN_URL=%q\n' "$listen_url" + } > "$CONFIG_FILE" +} + +common_write_launcher() { + local codex_bin="$1" + local listen_url="$2" + + common_ensure_dirs + + { + printf '#!/usr/bin/env bash\n' + printf 'set -euo pipefail\n' + printf 'exec ' + printf '%q ' "$codex_bin" "app-server" "--listen" "$listen_url" + printf '\n' + } > "$LAUNCHER_FILE" + + chmod +x "$LAUNCHER_FILE" +} + +common_port_is_in_use() { + local port="$1" + + if command -v lsof >/dev/null 2>&1; then + lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 + return $? + fi + + if command -v ss >/dev/null 2>&1; then + ss -H -ltn 2>/dev/null | awk -v needle=":$port" ' + $4 ~ needle "$" { found = 1 } + END { exit found ? 0 : 1 } + ' + return $? + fi + + if command -v netstat >/dev/null 2>&1; then + netstat -an 2>/dev/null | awk -v needle=":$port" ' + $1 ~ /^tcp/ && $4 ~ needle "$" && $NF ~ /LISTEN|LISTENING/ { found = 1 } + END { exit found ? 0 : 1 } + ' + return $? + fi + + if command -v nc >/dev/null 2>&1; then + nc -z "$XWORKMATE_CODEX_ACP_DEFAULT_HOST" "$port" >/dev/null 2>&1 + return $? + fi + + common_die "Unable to detect port availability. Install one of lsof, ss, netstat, or nc." +} + +common_find_available_port() { + local preferred="$1" + local port + local max_port=$((preferred + XWORKMATE_CODEX_ACP_PORT_SCAN_LIMIT)) + + if (( max_port > 65535 )); then + max_port=65535 + fi + + for ((port = preferred; port <= max_port; port++)); do + if ! common_port_is_in_use "$port"; then + printf '%s\n' "$port" + return 0 + fi + done + + common_die "No free port found in range ${preferred}-${max_port}." +} + +common_select_port() { + local preferred="$1" + local current_port="${2:-}" + local preserve_current_busy="${3:-0}" + + common_validate_port "$preferred" + + if [[ "$preserve_current_busy" == "1" && -n "$current_port" && "$preferred" == "$current_port" ]]; then + printf '%s\n' "$preferred" + return 0 + fi + + if common_port_is_in_use "$preferred"; then + common_find_available_port "$preferred" + return 0 + fi + + printf '%s\n' "$preferred" +} + +common_endpoint_from_config() { + common_load_config + local port="${CODEX_ACP_PORT:-$XWORKMATE_CODEX_ACP_DEFAULT_PORT}" + common_validate_port "$port" + common_listen_url "$port" +} + +common_cleanup_dir_if_empty() { + local dir="$1" + if [[ -d "$dir" ]]; then + rmdir "$dir" 2>/dev/null || true + fi +} + +common_parse_args() { + ACTION="${1:-help}" + shift || true + + PORT_OVERRIDE="" + LOG_LINES="50" + + while [[ $# -gt 0 ]]; do + case "$1" in + --port) + shift + [[ $# -gt 0 ]] || common_die "--port requires a value" + PORT_OVERRIDE="$1" + shift + ;; + --lines) + shift + [[ $# -gt 0 ]] || common_die "--lines requires a value" + LOG_LINES="$1" + shift + ;; + -h|--help|help) + ACTION="help" + shift + ;; + *) + if [[ "$ACTION" == "set-port" && -z "$PORT_OVERRIDE" ]]; then + PORT_OVERRIDE="$1" + shift + continue + fi + common_die "Unknown argument: $1" + ;; + esac + done + + if [[ -n "$PORT_OVERRIDE" ]]; then + common_validate_port "$PORT_OVERRIDE" + fi + if [[ ! "$LOG_LINES" =~ ^[0-9]+$ ]]; then + common_die "Invalid --lines value: $LOG_LINES" + fi +} diff --git a/scripts/codex-acp-service-linux.sh b/scripts/codex-acp-service-linux.sh new file mode 100755 index 00000000..e3ae4c17 --- /dev/null +++ b/scripts/codex-acp-service-linux.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./codex-acp-service-common.sh +source "$SCRIPT_DIR/codex-acp-service-common.sh" + +SERVICE_NAME="${XWORKMATE_CODEX_ACP_SERVICE_NAME:-xworkmate-codex-acp.service}" +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" +SYSTEMD_USER_DIR="${XWORKMATE_CODEX_ACP_SYSTEMD_USER_DIR:-$XDG_CONFIG_HOME/systemd/user}" +CONFIG_DIR="${XWORKMATE_CODEX_ACP_CONFIG_DIR:-$XDG_CONFIG_HOME/xworkmate/codex-acp}" +RUNTIME_DIR="${XWORKMATE_CODEX_ACP_RUNTIME_DIR:-$XDG_STATE_HOME/xworkmate/codex-acp}" +LOG_DIR="" +CONFIG_FILE="$CONFIG_DIR/config.env" +LAUNCHER_FILE="$RUNTIME_DIR/run-codex-acp.sh" +SERVICE_FILE="$SYSTEMD_USER_DIR/$SERVICE_NAME" + +linux_has_systemctl() { + command -v systemctl >/dev/null 2>&1 +} + +linux_require_systemctl() { + if [[ "$XWORKMATE_CODEX_ACP_DRY_RUN" == "1" ]]; then + return 0 + fi + linux_has_systemctl || common_die "systemctl is required for Linux native service control." +} + +linux_usage() { + cat < [--port PORT] [--lines N] + +Commands: + install Write user unit + enable/start Codex ACP service + start Start service using configured or auto-selected port + stop Stop the user service + restart Restart service, re-picking a free port if needed + status Show service state and endpoint + endpoint Print configured websocket endpoint + logs Show recent journal lines + set-port PORT Reconfigure the service port, then restart if active + uninstall Stop service and remove systemd user unit + generated files + help Show this message + +Defaults: + host $XWORKMATE_CODEX_ACP_DEFAULT_HOST + default port $XWORKMATE_CODEX_ACP_DEFAULT_PORT + +Environment: + CODEX_BIN=/absolute/path/to/codex + XWORKMATE_CODEX_ACP_DRY_RUN=1 Generate files without calling systemctl +EOF +} + +linux_is_active() { + if ! linux_has_systemctl; then + return 1 + fi + systemctl --user is-active --quiet "$SERVICE_NAME" +} + +linux_state() { + if ! linux_has_systemctl; then + printf 'unavailable (systemctl missing)\n' + return 0 + fi + + if systemctl --user is-enabled "$SERVICE_NAME" >/dev/null 2>&1; then + if linux_is_active; then + printf 'active\n' + return 0 + fi + printf 'enabled\n' + return 0 + fi + if linux_is_active; then + printf 'active\n' + return 0 + fi + printf 'inactive\n' +} + +linux_write_service_file() { + common_ensure_dirs + mkdir -p "$SYSTEMD_USER_DIR" + + cat > "$SERVICE_FILE" </dev/null 2>&1; then + printf 'gui/%s\n' "$UID" + return 0 + fi + printf 'user/%s\n' "$UID" +} + +SERVICE_LABEL="${XWORKMATE_CODEX_ACP_SERVICE_LABEL:-plus.svc.xworkmate.codex-acp}" +CONFIG_DIR="${XWORKMATE_CODEX_ACP_CONFIG_DIR:-$HOME/Library/Application Support/XWorkmate/codex-acp}" +RUNTIME_DIR="${XWORKMATE_CODEX_ACP_RUNTIME_DIR:-$CONFIG_DIR/runtime}" +LOG_DIR="${XWORKMATE_CODEX_ACP_LOG_DIR:-$HOME/Library/Logs/XWorkmate/codex-acp}" +CONFIG_FILE="$CONFIG_DIR/config.env" +LAUNCHER_FILE="$RUNTIME_DIR/run-codex-acp.sh" +PLIST_DIR="${XWORKMATE_CODEX_ACP_LAUNCHAGENTS_DIR:-$HOME/Library/LaunchAgents}" +SERVICE_FILE="$PLIST_DIR/$SERVICE_LABEL.plist" +LAUNCHCTL_DOMAIN="${XWORKMATE_CODEX_ACP_LAUNCHCTL_DOMAIN:-$(macos_launchctl_domain)}" + +macos_usage() { + cat < [--port PORT] [--lines N] + +Commands: + install Write LaunchAgent + start Codex ACP service + start Start service using configured or auto-selected port + stop Stop the LaunchAgent + restart Restart service, re-picking a free port if needed + status Show service state and endpoint + endpoint Print configured websocket endpoint + logs Tail saved stdout/stderr logs + set-port PORT Reconfigure the service port, then restart if loaded + uninstall Stop service and remove LaunchAgent + generated files + help Show this message + +Defaults: + host $XWORKMATE_CODEX_ACP_DEFAULT_HOST + default port $XWORKMATE_CODEX_ACP_DEFAULT_PORT + launchctl $LAUNCHCTL_DOMAIN + +Environment: + CODEX_BIN=/absolute/path/to/codex + XWORKMATE_CODEX_ACP_DRY_RUN=1 Generate files without calling launchctl +EOF +} + +macos_is_loaded() { + launchctl print "$LAUNCHCTL_DOMAIN/$SERVICE_LABEL" >/dev/null 2>&1 +} + +macos_state() { + if ! macos_is_loaded; then + printf 'unloaded\n' + return 0 + fi + + local state + state="$(launchctl print "$LAUNCHCTL_DOMAIN/$SERVICE_LABEL" 2>/dev/null | awk -F'= ' '/state =/ { print $2; exit }')" + if [[ -n "$state" ]]; then + printf '%s\n' "$state" + return 0 + fi + printf 'loaded\n' +} + +macos_write_service_file() { + common_ensure_dirs + mkdir -p "$PLIST_DIR" + + cat > "$SERVICE_FILE" < + + + + Label + $SERVICE_LABEL + ProgramArguments + + $LAUNCHER_FILE + + RunAtLoad + + KeepAlive + + WorkingDirectory + $HOME + StandardOutPath + $LOG_DIR/stdout.log + StandardErrorPath + $LOG_DIR/stderr.log + EnvironmentVariables + + PATH + $PATH + + + +EOF +} + +macos_prepare_service() { + common_load_config + + local configured_port="${CODEX_ACP_PORT:-$XWORKMATE_CODEX_ACP_DEFAULT_PORT}" + local requested_port="${PORT_OVERRIDE:-$configured_port}" + local preserve_busy="0" + if macos_is_loaded && [[ "$requested_port" == "$configured_port" ]]; then + preserve_busy="1" + fi + + local codex_bin="${CODEX_BIN:-}" + if [[ -z "$codex_bin" || ! -x "$codex_bin" ]]; then + codex_bin="$(common_resolve_codex_bin)" + fi + + local selected_port + selected_port="$(common_select_port "$requested_port" "$configured_port" "$preserve_busy")" + if [[ "$selected_port" != "$requested_port" ]]; then + common_warn "Port $requested_port is busy; using $selected_port instead." + fi + + local listen_url + listen_url="$(common_listen_url "$selected_port")" + + common_write_config "$codex_bin" "$selected_port" + common_write_launcher "$codex_bin" "$listen_url" + macos_write_service_file +} + +macos_activate_service() { + common_run_service_cmd_allow_fail launchctl bootout "$LAUNCHCTL_DOMAIN" "$SERVICE_FILE" + common_run_service_cmd launchctl bootstrap "$LAUNCHCTL_DOMAIN" "$SERVICE_FILE" + common_run_service_cmd launchctl kickstart -k "$LAUNCHCTL_DOMAIN/$SERVICE_LABEL" +} + +macos_stop_service() { + if ! macos_is_loaded; then + common_info "Service is not loaded." + return 0 + fi + common_run_service_cmd launchctl bootout "$LAUNCHCTL_DOMAIN" "$SERVICE_FILE" +} + +macos_status() { + common_info "platform: macOS" + common_info "label: $SERVICE_LABEL" + common_info "state: $(macos_state)" + common_info "endpoint: $(common_endpoint_from_config)" + common_info "launch agent: $SERVICE_FILE" + common_info "config: $CONFIG_FILE" + common_info "logs:" + common_info " $LOG_DIR/stdout.log" + common_info " $LOG_DIR/stderr.log" +} + +macos_logs() { + mkdir -p "$LOG_DIR" + touch "$LOG_DIR/stdout.log" "$LOG_DIR/stderr.log" + common_info "==> $LOG_DIR/stdout.log <==" + tail -n "$LOG_LINES" "$LOG_DIR/stdout.log" + common_info + common_info "==> $LOG_DIR/stderr.log <==" + tail -n "$LOG_LINES" "$LOG_DIR/stderr.log" +} + +macos_uninstall() { + macos_stop_service + rm -f "$SERVICE_FILE" "$LAUNCHER_FILE" "$CONFIG_FILE" + common_cleanup_dir_if_empty "$RUNTIME_DIR" + common_cleanup_dir_if_empty "$CONFIG_DIR" + common_info "Removed LaunchAgent: $SERVICE_LABEL" +} + +common_parse_args "$@" + +case "$ACTION" in + install) + macos_prepare_service + macos_activate_service + common_info "Codex ACP service ready at $(common_endpoint_from_config)" + ;; + start) + if macos_is_loaded && [[ -z "$PORT_OVERRIDE" ]]; then + common_info "Service already loaded at $(common_endpoint_from_config)" + exit 0 + fi + macos_prepare_service + macos_activate_service + common_info "Codex ACP service started at $(common_endpoint_from_config)" + ;; + stop) + macos_stop_service + ;; + restart) + macos_prepare_service + macos_activate_service + common_info "Codex ACP service restarted at $(common_endpoint_from_config)" + ;; + status) + macos_status + ;; + endpoint) + common_endpoint_from_config + ;; + logs) + macos_logs + ;; + set-port) + [[ -n "$PORT_OVERRIDE" ]] || common_die "set-port requires a port value" + macos_prepare_service + if macos_is_loaded; then + macos_activate_service + common_info "Codex ACP service moved to $(common_endpoint_from_config)" + else + common_info "Configured Codex ACP service for $(common_endpoint_from_config)" + fi + ;; + uninstall) + macos_uninstall + ;; + help) + macos_usage + ;; + *) + common_die "Unknown command: $ACTION" + ;; +esac diff --git a/scripts/codex-acp-service.sh b/scripts/codex-acp-service.sh new file mode 100755 index 00000000..054508d6 --- /dev/null +++ b/scripts/codex-acp-service.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +case "$(uname -s)" in + Darwin) + exec "$SCRIPT_DIR/codex-acp-service-macos.sh" "$@" + ;; + Linux) + exec "$SCRIPT_DIR/codex-acp-service-linux.sh" "$@" + ;; + *) + echo "Unsupported platform: $(uname -s)" >&2 + exit 1 + ;; +esac