From aea17e61772b3a656796d092d0ecbda13a28257c Mon Sep 17 00:00:00 2001 From: Joel Teply Date: Tue, 28 Apr 2026 10:52:07 -0500 Subject: [PATCH] =?UTF-8?q?refactor(airc-bash):=20extract=20cmd=5Fdaemon?= =?UTF-8?q?=20family=20=E2=80=94=20Phase=203=20file=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the cmd_daemon command group (cmd_daemon + cmd_daemon_install/ uninstall/status/log + 8 private _daemon_* helpers) out of the airc top-level into lib/airc_bash/cmd_daemon.sh, sourced via the same lib-dir resolver as cmd_doctor.sh / cmd_connect.sh / platform_adapters.sh. airc: 5265 → 4834 lines (-431) lib/airc_bash/cmd_daemon.sh: +461 (432 body + 29 header) Behavior unchanged. Cross-references resolve at call-time: - cmd_daemon.sh calls airc top-level helpers (die, detect_platform) - airc top-level (monitor self-heal, line ~1292) calls _daemon_installed defined in cmd_daemon.sh Verified: - bash -n on both files - airc daemon status — full plist/launchctl readout, log path correct Stacks alongside #213 (cmd_connect extraction). Each PR independently removes a major block from the bash monolith. Co-Authored-By: Claude Opus 4.7 (1M context) --- airc | 444 +--------------------------------- lib/airc_bash/cmd_daemon.sh | 461 ++++++++++++++++++++++++++++++++++++ 2 files changed, 473 insertions(+), 432 deletions(-) create mode 100644 lib/airc_bash/cmd_daemon.sh diff --git a/airc b/airc index 06b851b..11d5ddb 100755 --- a/airc +++ b/airc @@ -4714,438 +4714,18 @@ else: fi } -# ── cmd_daemon: install / manage the OS auto-restart for `airc connect` ──── -# Issue followup to #39 substrate: the channel must auto-resume across machine -# sleep/wake/crash so users walk away and come back to a live mesh. Without -# this, every laptop sleep kills airc + the user must remember to restart it. -# -# Implementation: install a platform-native autostart that wraps `airc connect` -# with KeepAlive/Restart=always. AIRC_BACKGROUND_OK=1 is set in the env so -# airc's heartbeat-stdout-pipe-trap doesn't exit-3 under launchd/systemd -# (which have no notification-consumer reading stdout). -# -# Subcommands: -# airc daemon install Install + start the autostart entry -# airc daemon uninstall Stop + remove the autostart entry -# airc daemon status Show install state + running pid + log path -# airc daemon log [N] Tail the daemon stdout log -# -# Scope: defaults to the GLOBAL scope ($HOME/.airc), since the daemon is the -# user's "always-on" mesh presence — not tied to a specific project dir. If -# the user wants a per-project always-on daemon, they pass AIRC_HOME= -# in the environment when running install (and the generated unit/plist -# will carry that scope). -cmd_daemon() { - local action="${1:-status}" - shift 2>/dev/null || true - case "$action" in - install) cmd_daemon_install "$@" ;; - uninstall|remove|stop) cmd_daemon_uninstall "$@" ;; - status) cmd_daemon_status "$@" ;; - log|logs) cmd_daemon_log "$@" ;; - *) die "Usage: airc daemon [install|uninstall|status|log]" ;; - esac -} - -# Resolve the absolute path to airc binary that should run under the daemon. -# install.sh symlinks $HOME/.local/bin/airc → $AIRC_DIR/airc; we want the -# real path so a future `airc update` (which mutates $AIRC_DIR/airc in -# place) is picked up by launchd/systemd without re-installing the unit. -_daemon_airc_path() { - local airc_link="${HOME}/.local/bin/airc" - if [ -L "$airc_link" ] || [ -x "$airc_link" ]; then - echo "$airc_link" - elif [ -x "${AIRC_DIR:-$HOME/.airc-src}/airc" ]; then - echo "${AIRC_DIR:-$HOME/.airc-src}/airc" - else - echo "/usr/local/bin/airc" # last-resort guess; install will fail loud if wrong - fi -} - -# The scope the daemon will run under. Mirrors detect_scope() (line 135) -# so `airc daemon install` from a project dir captures THAT dir's -# .airc as the daemon's scope -- otherwise the daemon spawns a monitor -# pointed at $HOME/.airc (empty / wrong room) while the user's actual -# join state lives at $cwd/.airc. Joel 2026-04-28: "lol obv if it -# worked you would have a monitor and be online. FAIL" -- caught the -# scope mismatch on continuum-b69f's box. -_daemon_scope() { - if [ -n "${AIRC_HOME:-}" ]; then - echo "$AIRC_HOME" - else - echo "$(pwd -P)/.airc" - fi -} - -# Returns 0 if the autostart daemon (launchd / systemd unit) is installed -# on this OS, 1 otherwise. Used by the monitor escalation banner (#184) -# to tell the user whether the upcoming exit-99 will trigger self-heal -# (daemon present) or just kill the relay silently (no daemon — they -# need to `airc join` again). -_daemon_installed() { - local os; os=$(detect_platform) - case "$os" in - darwin) - [ -f "$HOME/Library/LaunchAgents/com.cambriantech.airc.plist" ] && return 0 ;; - linux|wsl) - [ -f "$HOME/.config/systemd/user/airc.service" ] && return 0 ;; - windows) - reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v airc-monitor >/dev/null 2>&1 && return 0 ;; - esac - return 1 -} - -cmd_daemon_install() { - local os; os=$(detect_platform) - local airc_bin; airc_bin=$(_daemon_airc_path) - local scope; scope=$(_daemon_scope) - mkdir -p "$scope" - - case "$os" in - darwin) _daemon_install_launchd "$airc_bin" "$scope" ;; - linux|wsl) _daemon_install_systemd "$airc_bin" "$scope" "$os" ;; - windows) _daemon_install_schtasks "$airc_bin" "$scope" ;; - *) die "Daemon install not supported on $(uname -s). Manual workaround: run 'airc connect' under your platform's preferred autostart mechanism." ;; - esac -} - -# Print the common "daemon installed; here's where to look" footer. -# Three platform installers used to duplicate this 5-line block; now -# they call this helper. Pass the platform-specific lead line as $1 and -# any optional trailing note as $2 (heredoc-style multi-line OK). -_daemon_install_done() { - local lead="$1" scope="$2" note="${3:-}" - echo " ✓ $lead" - echo " airc will now auto-start at login + restart on exit." - echo " Logs: $scope/daemon.log" - echo " Status: airc daemon status" - if [ -n "$note" ]; then echo ""; printf ' %s\n' "$note"; fi -} - -_daemon_install_launchd() { - local airc_bin="$1" scope="$2" - local plist_dir="$HOME/Library/LaunchAgents" - local plist_path="$plist_dir/com.cambriantech.airc.plist" - mkdir -p "$plist_dir" - cat > "$plist_path" < - - - - Label - com.cambriantech.airc - ProgramArguments - - ${airc_bin} - connect - - EnvironmentVariables - - AIRC_BACKGROUND_OK - 1 - AIRC_HOME - ${scope} - HOME - ${HOME} - PATH - /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${HOME}/.local/bin - - RunAtLoad - - KeepAlive - - StandardOutPath - ${scope}/daemon.log - StandardErrorPath - ${scope}/daemon.err - ProcessType - Background - ThrottleInterval - 10 - - -PLIST - echo " Wrote $plist_path" - # Bootout first to reset any prior load (idempotent install). - launchctl bootout "gui/$(id -u)/com.cambriantech.airc" 2>/dev/null || true - launchctl bootstrap "gui/$(id -u)" "$plist_path" 2>&1 \ - || die "launchctl bootstrap failed. Plist written but not loaded; check Console.app for errors." - launchctl enable "gui/$(id -u)/com.cambriantech.airc" 2>/dev/null || true - _daemon_install_done "Loaded into launchd (gui/$(id -u)/com.cambriantech.airc)" "$scope" \ - "Note: if 'airc canary' / gist push fails under launchd, the gh keychain may not be unlocked at boot. Workaround: 'gh auth status' once after login to unlock; airc daemon picks it up on next restart." -} - -_daemon_install_schtasks() { - # Windows daemon via HKCU Run-key (no admin; HKCU\...\Run is user- - # scope, so per-user autostart at logon without UAC). PRs #200/#202 - # for the why; this function for the how. - local airc_bin="$1" scope="$2" - local entry_name="airc-monitor" - - # Find Git Bash — the launcher .bat needs it to exec airc. - local bash_exe="" - for c in 'C:\Program Files\Git\bin\bash.exe' 'C:\Program Files (x86)\Git\bin\bash.exe' "$HOME/AppData/Local/Programs/Git/bin/bash.exe"; do - local check_path; check_path=$(echo "$c" | sed 's|\\|/|g; s|^C:|/c|') - if [ -f "$c" ] || [ -f "$check_path" ]; then bash_exe="$c"; break; fi - done - [ -z "$bash_exe" ] && die "bash.exe not found at any standard Git for Windows path. Install Git for Windows + re-run." - - # Convert paths to Windows form; cmd.exe can't read /c/Users/... . - local airc_bin_win; airc_bin_win=$(_to_win_path "$airc_bin") - local scope_win; scope_win=$(_to_win_path "$scope") - - # Launcher .bat: cd to cwd (so airc's detect_scope finds /.airc), - # bash -c (not -lc, to keep cmd-set env), absolute unix airc path - # (bash -c doesn't read .bashrc so PATH won't have ~/.local/bin). - # Loop with 5s restart matches launchd KeepAlive / systemd Restart=always. - # See PR #202 for the bug history that necessitated each of those choices. - local cwd_win; cwd_win=$(_to_win_path "$(pwd -P)") - local airc_bin_unix; airc_bin_unix=$(_to_bash_path "$airc_bin") - [ -z "$airc_bin_unix" ] && airc_bin_unix="$airc_bin" - # Marker path the .bat polls to distinguish intentional re-exec - # (written by _reexec_into) from "actual crash" (#203/#204). - local marker_win; marker_win=$(_to_win_path "$scope/airc.reexec-marker") - local launcher_bash="$scope/airc-daemon.bat" - cat > "$launcher_bash" <nul 2>&1 - if not errorlevel 1 ( - echo [%date% %time%] airc re-exec'd into different mode ^(host-takeover or rejoin^); new process is now daemon, launcher exiting. >> daemon.err - del "$marker_win" >nul 2>&1 - exit /b 0 - ) -) -echo [%date% %time%] airc connect exited. Restarting in 5s. >> daemon.err -timeout /t 5 /nobreak >nul -goto loop -EOF - local launcher_win; launcher_win=$(_to_win_path "$launcher_bash") - - # `cmd /c start "" /MIN ` launches detached + minimized; empty "" - # is start's title slot. reg add /f is idempotent (overwrites prior). - local run_cmd="cmd /c start \"\" /MIN \"$launcher_win\"" - reg add "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v "$entry_name" //t REG_SZ //d "$run_cmd" //f >/dev/null 2>&1 \ - || die "reg add failed for HKCU Run\\$entry_name" - # Start now (no logout/login needed). Fires-and-forgets. - cmd //c start "" //MIN "$launcher_win" >/dev/null 2>&1 || true - - echo " ✓ Started monitor in detached cmd window (minimized)" - _daemon_install_done "Registered HKCU Run entry '$entry_name' (runs at every Windows logon)" "$scope" -} - -_daemon_install_systemd() { - local airc_bin="$1" scope="$2" os="$3" - local unit_dir="$HOME/.config/systemd/user" - local unit_path="$unit_dir/airc.service" - if ! command -v systemctl >/dev/null 2>&1; then - if [ "$os" = "wsl" ]; then - die "systemctl not found. Enable systemd in WSL: edit /etc/wsl.conf to add [boot]\nsystemd=true, then 'wsl --shutdown' from PowerShell + restart your distro." - else - die "systemctl not found. Daemon install requires systemd." - fi - fi - # Probe the user-level systemd bus BEFORE writing the unit. WSL2 ships - # systemctl on PATH but typically has init (not systemd) as PID 1, so - # `systemctl --user` returns "Failed to connect to bus" — we'd write - # the unit then fail to load it, leaving cruft on disk. Detect early. - if ! systemctl --user is-system-running >/dev/null 2>&1 \ - && ! systemctl --user list-units >/dev/null 2>&1; then - if [ "$os" = "wsl" ]; then - cat >&2 < "$unit_path" </dev/null \ - && echo " ✓ Unloaded from launchd" \ - || echo " (was not loaded)" - [ -f "$plist_path" ] && rm "$plist_path" && echo " ✓ Removed $plist_path" \ - || echo " (no plist on disk)" - ;; - linux|wsl) - systemctl --user disable --now airc.service 2>/dev/null \ - && echo " ✓ Stopped + disabled airc.service" \ - || echo " (was not enabled)" - local unit_path="$HOME/.config/systemd/user/airc.service" - [ -f "$unit_path" ] && rm "$unit_path" && systemctl --user daemon-reload && echo " ✓ Removed $unit_path" \ - || echo " (no unit on disk)" - ;; - windows) - local entry_name="airc-monitor" - if reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v "$entry_name" >/dev/null 2>&1; then - reg delete "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v "$entry_name" //f >/dev/null 2>&1 \ - && echo " ✓ Removed HKCU Run entry '$entry_name'" \ - || echo " (reg delete failed — try 'reg delete' manually)" - else - echo " (no Run entry '$entry_name' registered)" - fi - # Kill any currently-running daemon-launched airc-connect tree. - # Match on the launcher .bat path so we don't kill foreground - # `airc join` running in the user's terminal. - local scope; scope=$(_daemon_scope) - if ps -ef 2>/dev/null | grep 'airc-daemon.bat' | grep -v grep >/dev/null; then - ps -ef | grep 'airc-daemon.bat' | grep -v grep | awk '{print $2}' | while read pid; do - kill "$pid" 2>/dev/null || true - done - echo " ✓ Killed running daemon launcher process(es)" - fi - [ -f "$scope/airc-daemon.bat" ] && rm "$scope/airc-daemon.bat" \ - && echo " ✓ Removed $scope/airc-daemon.bat" - ;; - *) echo " Daemon uninstall not supported on $(uname -s)."; return 1 ;; - esac -} - -cmd_daemon_status() { - local os; os=$(detect_platform) - case "$os" in - darwin) - local plist_path="$HOME/Library/LaunchAgents/com.cambriantech.airc.plist" - if [ -f "$plist_path" ]; then - echo " Plist: $plist_path" - # launchctl print returns rich state; grep the key fields. - local state; state=$(launchctl print "gui/$(id -u)/com.cambriantech.airc" 2>/dev/null \ - | grep -E 'state =|pid =|last exit code' | head -3) - if [ -n "$state" ]; then - echo " Loaded: yes" - printf '%s\n' "$state" | sed 's/^[[:space:]]*/ /' - else - echo " Loaded: no (plist present but not bootstrapped — try 'airc daemon install' to reload)" - fi - local scope; scope=$(_daemon_scope) - echo " Logs: $scope/daemon.log" - else - echo " No daemon installed. Run: airc daemon install" - fi - ;; - linux|wsl) - local unit_path="$HOME/.config/systemd/user/airc.service" - if [ -f "$unit_path" ]; then - echo " Unit: $unit_path" - local active; active=$(systemctl --user is-active airc.service 2>/dev/null) - local enabled; enabled=$(systemctl --user is-enabled airc.service 2>/dev/null) - echo " Active: $active" - echo " Enabled: $enabled" - local scope; scope=$(_daemon_scope) - echo " Logs: $scope/daemon.log (journalctl --user -u airc -f for live)" - else - echo " No daemon installed. Run: airc daemon install" - fi - ;; - windows) - local entry_name="airc-monitor" - if reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v "$entry_name" >/dev/null 2>&1; then - echo " Type: HKCU Run-key (per-user logon autostart, no admin)" - echo " Entry: $entry_name" - local scope; scope=$(_daemon_scope) - echo " Logs: $scope/daemon.log" - echo " Errors: $scope/daemon.err" - echo " Launcher: $scope/airc-daemon.bat" - # Is the daemon-launched airc actually running right now? The - # launcher .bat spawns bash + airc-connect then exits, so we - # look for the airc-connect process (PPID=1 = orphaned-into- - # init, which is what `start /B` produces on Windows). Falling - # back to airc.pid lookup if that fails. - local live_pid - live_pid=$(ps -ef 2>/dev/null | awk '$3 == 1 && /airc.*connect/ && !/grep/ {print $2; exit}') - if [ -z "$live_pid" ] && [ -f "$scope/airc.pid" ]; then - local pidfile_pid - pidfile_pid=$(head -1 "$scope/airc.pid" 2>/dev/null | tr -d '[:space:]') - if [ -n "$pidfile_pid" ] && kill -0 "$pidfile_pid" 2>/dev/null; then - live_pid="$pidfile_pid (from airc.pid)" - fi - fi - if [ -n "$live_pid" ]; then - echo " Status: RUNNING (PID $live_pid)" - else - echo " Status: registered (will start at next logon — or 'airc daemon install' to start now)" - fi - else - echo " No daemon installed. Run: airc daemon install" - fi - ;; - *) echo " Daemon status not supported on $(uname -s)." ;; - esac -} - -cmd_daemon_log() { - local n="${1:-50}" - local scope; scope=$(_daemon_scope) - local log="$scope/daemon.log" - if [ ! -f "$log" ]; then - echo " No log at $log. Daemon may not have started yet." - return 1 - fi - tail -"$n" "$log" -} +# cmd_daemon family extracted to lib/airc_bash/cmd_daemon.sh +# (#152 Phase 3 file split, follow-up to cmd_doctor.sh / cmd_connect.sh). +# The block holds cmd_daemon + cmd_daemon_install/uninstall/status/log +# plus all _daemon_* private helpers. +if [ -n "${_airc_lib_dir:-}" ] && [ -f "$_airc_lib_dir/airc_bash/cmd_daemon.sh" ]; then + # shellcheck source=lib/airc_bash/cmd_daemon.sh + source "$_airc_lib_dir/airc_bash/cmd_daemon.sh" +else + echo "ERROR: airc_bash/cmd_daemon.sh not found via lib-dir resolver." >&2 + echo " Resolved lib_dir: ${_airc_lib_dir:-}" >&2 + exit 1 +fi # cmd_doctor + helpers extracted to lib/airc_bash/cmd_doctor.sh # (#152 Phase 3 file split). Sourced via the lib-dir resolver. diff --git a/lib/airc_bash/cmd_daemon.sh b/lib/airc_bash/cmd_daemon.sh new file mode 100644 index 0000000..8879715 --- /dev/null +++ b/lib/airc_bash/cmd_daemon.sh @@ -0,0 +1,461 @@ +# Sourced by airc. cmd_daemon family — install / status / uninstall / +# log of the OS auto-restart for `airc connect`. +# +# Functions exported back to airc's dispatch: +# cmd_daemon — verb router (install|status|uninstall|log) +# cmd_daemon_install — top-level installer, branches per platform +# cmd_daemon_uninstall — top-level uninstaller +# cmd_daemon_status — dump platform-native unit/plist state + log tail +# cmd_daemon_log — `tail` the daemon stdout log +# +# Private helpers (all `_daemon_*` named): +# _daemon_airc_path — resolve the absolute path airc was invoked as +# _daemon_scope — pick install scope (defaults to $HOME/.airc) +# _daemon_installed — fast yes/no probe used by monitor self-heal +# _daemon_install_done — shared post-install confirmation print +# _daemon_install_launchd — macOS plist writer + launchctl bootstrap +# _daemon_install_schtasks— Windows HKCU Run-key registration +# _daemon_install_systemd — Linux/WSL systemd-user unit writer +# +# External cross-references (resolved at call time, defined inline in airc +# top-level): die, detect_platform. Also called BY cmd_connect / monitor +# (`_daemon_installed` for the no-claude-left-behind self-heal probe). +# +# Extracted from airc as part of #152 Phase 3 file split, after Joel +# 2026-04-27 push: "shell scripts are like classes; the 5200-line bash +# monolith was wrong." This is the cmd_daemon group — each command-family +# becomes one .sh file, mirroring the cmd_doctor.sh / cmd_connect.sh +# extraction pattern. + +# ── cmd_daemon: install / manage the OS auto-restart for `airc connect` ──── +# Issue followup to #39 substrate: the channel must auto-resume across machine +# sleep/wake/crash so users walk away and come back to a live mesh. Without +# this, every laptop sleep kills airc + the user must remember to restart it. +# +# Implementation: install a platform-native autostart that wraps `airc connect` +# with KeepAlive/Restart=always. AIRC_BACKGROUND_OK=1 is set in the env so +# airc's heartbeat-stdout-pipe-trap doesn't exit-3 under launchd/systemd +# (which have no notification-consumer reading stdout). +# +# Subcommands: +# airc daemon install Install + start the autostart entry +# airc daemon uninstall Stop + remove the autostart entry +# airc daemon status Show install state + running pid + log path +# airc daemon log [N] Tail the daemon stdout log +# +# Scope: defaults to the GLOBAL scope ($HOME/.airc), since the daemon is the +# user's "always-on" mesh presence — not tied to a specific project dir. If +# the user wants a per-project always-on daemon, they pass AIRC_HOME= +# in the environment when running install (and the generated unit/plist +# will carry that scope). +cmd_daemon() { + local action="${1:-status}" + shift 2>/dev/null || true + case "$action" in + install) cmd_daemon_install "$@" ;; + uninstall|remove|stop) cmd_daemon_uninstall "$@" ;; + status) cmd_daemon_status "$@" ;; + log|logs) cmd_daemon_log "$@" ;; + *) die "Usage: airc daemon [install|uninstall|status|log]" ;; + esac +} + +# Resolve the absolute path to airc binary that should run under the daemon. +# install.sh symlinks $HOME/.local/bin/airc → $AIRC_DIR/airc; we want the +# real path so a future `airc update` (which mutates $AIRC_DIR/airc in +# place) is picked up by launchd/systemd without re-installing the unit. +_daemon_airc_path() { + local airc_link="${HOME}/.local/bin/airc" + if [ -L "$airc_link" ] || [ -x "$airc_link" ]; then + echo "$airc_link" + elif [ -x "${AIRC_DIR:-$HOME/.airc-src}/airc" ]; then + echo "${AIRC_DIR:-$HOME/.airc-src}/airc" + else + echo "/usr/local/bin/airc" # last-resort guess; install will fail loud if wrong + fi +} + +# The scope the daemon will run under. Mirrors detect_scope() (line 135) +# so `airc daemon install` from a project dir captures THAT dir's +# .airc as the daemon's scope -- otherwise the daemon spawns a monitor +# pointed at $HOME/.airc (empty / wrong room) while the user's actual +# join state lives at $cwd/.airc. Joel 2026-04-28: "lol obv if it +# worked you would have a monitor and be online. FAIL" -- caught the +# scope mismatch on continuum-b69f's box. +_daemon_scope() { + if [ -n "${AIRC_HOME:-}" ]; then + echo "$AIRC_HOME" + else + echo "$(pwd -P)/.airc" + fi +} + +# Returns 0 if the autostart daemon (launchd / systemd unit) is installed +# on this OS, 1 otherwise. Used by the monitor escalation banner (#184) +# to tell the user whether the upcoming exit-99 will trigger self-heal +# (daemon present) or just kill the relay silently (no daemon — they +# need to `airc join` again). +_daemon_installed() { + local os; os=$(detect_platform) + case "$os" in + darwin) + [ -f "$HOME/Library/LaunchAgents/com.cambriantech.airc.plist" ] && return 0 ;; + linux|wsl) + [ -f "$HOME/.config/systemd/user/airc.service" ] && return 0 ;; + windows) + reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v airc-monitor >/dev/null 2>&1 && return 0 ;; + esac + return 1 +} + +cmd_daemon_install() { + local os; os=$(detect_platform) + local airc_bin; airc_bin=$(_daemon_airc_path) + local scope; scope=$(_daemon_scope) + mkdir -p "$scope" + + case "$os" in + darwin) _daemon_install_launchd "$airc_bin" "$scope" ;; + linux|wsl) _daemon_install_systemd "$airc_bin" "$scope" "$os" ;; + windows) _daemon_install_schtasks "$airc_bin" "$scope" ;; + *) die "Daemon install not supported on $(uname -s). Manual workaround: run 'airc connect' under your platform's preferred autostart mechanism." ;; + esac +} + +# Print the common "daemon installed; here's where to look" footer. +# Three platform installers used to duplicate this 5-line block; now +# they call this helper. Pass the platform-specific lead line as $1 and +# any optional trailing note as $2 (heredoc-style multi-line OK). +_daemon_install_done() { + local lead="$1" scope="$2" note="${3:-}" + echo " ✓ $lead" + echo " airc will now auto-start at login + restart on exit." + echo " Logs: $scope/daemon.log" + echo " Status: airc daemon status" + if [ -n "$note" ]; then echo ""; printf ' %s\n' "$note"; fi +} + +_daemon_install_launchd() { + local airc_bin="$1" scope="$2" + local plist_dir="$HOME/Library/LaunchAgents" + local plist_path="$plist_dir/com.cambriantech.airc.plist" + mkdir -p "$plist_dir" + cat > "$plist_path" < + + + + Label + com.cambriantech.airc + ProgramArguments + + ${airc_bin} + connect + + EnvironmentVariables + + AIRC_BACKGROUND_OK + 1 + AIRC_HOME + ${scope} + HOME + ${HOME} + PATH + /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${HOME}/.local/bin + + RunAtLoad + + KeepAlive + + StandardOutPath + ${scope}/daemon.log + StandardErrorPath + ${scope}/daemon.err + ProcessType + Background + ThrottleInterval + 10 + + +PLIST + echo " Wrote $plist_path" + # Bootout first to reset any prior load (idempotent install). + launchctl bootout "gui/$(id -u)/com.cambriantech.airc" 2>/dev/null || true + launchctl bootstrap "gui/$(id -u)" "$plist_path" 2>&1 \ + || die "launchctl bootstrap failed. Plist written but not loaded; check Console.app for errors." + launchctl enable "gui/$(id -u)/com.cambriantech.airc" 2>/dev/null || true + _daemon_install_done "Loaded into launchd (gui/$(id -u)/com.cambriantech.airc)" "$scope" \ + "Note: if 'airc canary' / gist push fails under launchd, the gh keychain may not be unlocked at boot. Workaround: 'gh auth status' once after login to unlock; airc daemon picks it up on next restart." +} + +_daemon_install_schtasks() { + # Windows daemon via HKCU Run-key (no admin; HKCU\...\Run is user- + # scope, so per-user autostart at logon without UAC). PRs #200/#202 + # for the why; this function for the how. + local airc_bin="$1" scope="$2" + local entry_name="airc-monitor" + + # Find Git Bash — the launcher .bat needs it to exec airc. + local bash_exe="" + for c in 'C:\Program Files\Git\bin\bash.exe' 'C:\Program Files (x86)\Git\bin\bash.exe' "$HOME/AppData/Local/Programs/Git/bin/bash.exe"; do + local check_path; check_path=$(echo "$c" | sed 's|\\|/|g; s|^C:|/c|') + if [ -f "$c" ] || [ -f "$check_path" ]; then bash_exe="$c"; break; fi + done + [ -z "$bash_exe" ] && die "bash.exe not found at any standard Git for Windows path. Install Git for Windows + re-run." + + # Convert paths to Windows form; cmd.exe can't read /c/Users/... . + local airc_bin_win; airc_bin_win=$(_to_win_path "$airc_bin") + local scope_win; scope_win=$(_to_win_path "$scope") + + # Launcher .bat: cd to cwd (so airc's detect_scope finds /.airc), + # bash -c (not -lc, to keep cmd-set env), absolute unix airc path + # (bash -c doesn't read .bashrc so PATH won't have ~/.local/bin). + # Loop with 5s restart matches launchd KeepAlive / systemd Restart=always. + # See PR #202 for the bug history that necessitated each of those choices. + local cwd_win; cwd_win=$(_to_win_path "$(pwd -P)") + local airc_bin_unix; airc_bin_unix=$(_to_bash_path "$airc_bin") + [ -z "$airc_bin_unix" ] && airc_bin_unix="$airc_bin" + # Marker path the .bat polls to distinguish intentional re-exec + # (written by _reexec_into) from "actual crash" (#203/#204). + local marker_win; marker_win=$(_to_win_path "$scope/airc.reexec-marker") + local launcher_bash="$scope/airc-daemon.bat" + cat > "$launcher_bash" <nul 2>&1 + if not errorlevel 1 ( + echo [%date% %time%] airc re-exec'd into different mode ^(host-takeover or rejoin^); new process is now daemon, launcher exiting. >> daemon.err + del "$marker_win" >nul 2>&1 + exit /b 0 + ) +) +echo [%date% %time%] airc connect exited. Restarting in 5s. >> daemon.err +timeout /t 5 /nobreak >nul +goto loop +EOF + local launcher_win; launcher_win=$(_to_win_path "$launcher_bash") + + # `cmd /c start "" /MIN ` launches detached + minimized; empty "" + # is start's title slot. reg add /f is idempotent (overwrites prior). + local run_cmd="cmd /c start \"\" /MIN \"$launcher_win\"" + reg add "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v "$entry_name" //t REG_SZ //d "$run_cmd" //f >/dev/null 2>&1 \ + || die "reg add failed for HKCU Run\\$entry_name" + # Start now (no logout/login needed). Fires-and-forgets. + cmd //c start "" //MIN "$launcher_win" >/dev/null 2>&1 || true + + echo " ✓ Started monitor in detached cmd window (minimized)" + _daemon_install_done "Registered HKCU Run entry '$entry_name' (runs at every Windows logon)" "$scope" +} + +_daemon_install_systemd() { + local airc_bin="$1" scope="$2" os="$3" + local unit_dir="$HOME/.config/systemd/user" + local unit_path="$unit_dir/airc.service" + if ! command -v systemctl >/dev/null 2>&1; then + if [ "$os" = "wsl" ]; then + die "systemctl not found. Enable systemd in WSL: edit /etc/wsl.conf to add [boot]\nsystemd=true, then 'wsl --shutdown' from PowerShell + restart your distro." + else + die "systemctl not found. Daemon install requires systemd." + fi + fi + # Probe the user-level systemd bus BEFORE writing the unit. WSL2 ships + # systemctl on PATH but typically has init (not systemd) as PID 1, so + # `systemctl --user` returns "Failed to connect to bus" — we'd write + # the unit then fail to load it, leaving cruft on disk. Detect early. + if ! systemctl --user is-system-running >/dev/null 2>&1 \ + && ! systemctl --user list-units >/dev/null 2>&1; then + if [ "$os" = "wsl" ]; then + cat >&2 < "$unit_path" </dev/null \ + && echo " ✓ Unloaded from launchd" \ + || echo " (was not loaded)" + [ -f "$plist_path" ] && rm "$plist_path" && echo " ✓ Removed $plist_path" \ + || echo " (no plist on disk)" + ;; + linux|wsl) + systemctl --user disable --now airc.service 2>/dev/null \ + && echo " ✓ Stopped + disabled airc.service" \ + || echo " (was not enabled)" + local unit_path="$HOME/.config/systemd/user/airc.service" + [ -f "$unit_path" ] && rm "$unit_path" && systemctl --user daemon-reload && echo " ✓ Removed $unit_path" \ + || echo " (no unit on disk)" + ;; + windows) + local entry_name="airc-monitor" + if reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v "$entry_name" >/dev/null 2>&1; then + reg delete "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v "$entry_name" //f >/dev/null 2>&1 \ + && echo " ✓ Removed HKCU Run entry '$entry_name'" \ + || echo " (reg delete failed — try 'reg delete' manually)" + else + echo " (no Run entry '$entry_name' registered)" + fi + # Kill any currently-running daemon-launched airc-connect tree. + # Match on the launcher .bat path so we don't kill foreground + # `airc join` running in the user's terminal. + local scope; scope=$(_daemon_scope) + if ps -ef 2>/dev/null | grep 'airc-daemon.bat' | grep -v grep >/dev/null; then + ps -ef | grep 'airc-daemon.bat' | grep -v grep | awk '{print $2}' | while read pid; do + kill "$pid" 2>/dev/null || true + done + echo " ✓ Killed running daemon launcher process(es)" + fi + [ -f "$scope/airc-daemon.bat" ] && rm "$scope/airc-daemon.bat" \ + && echo " ✓ Removed $scope/airc-daemon.bat" + ;; + *) echo " Daemon uninstall not supported on $(uname -s)."; return 1 ;; + esac +} + +cmd_daemon_status() { + local os; os=$(detect_platform) + case "$os" in + darwin) + local plist_path="$HOME/Library/LaunchAgents/com.cambriantech.airc.plist" + if [ -f "$plist_path" ]; then + echo " Plist: $plist_path" + # launchctl print returns rich state; grep the key fields. + local state; state=$(launchctl print "gui/$(id -u)/com.cambriantech.airc" 2>/dev/null \ + | grep -E 'state =|pid =|last exit code' | head -3) + if [ -n "$state" ]; then + echo " Loaded: yes" + printf '%s\n' "$state" | sed 's/^[[:space:]]*/ /' + else + echo " Loaded: no (plist present but not bootstrapped — try 'airc daemon install' to reload)" + fi + local scope; scope=$(_daemon_scope) + echo " Logs: $scope/daemon.log" + else + echo " No daemon installed. Run: airc daemon install" + fi + ;; + linux|wsl) + local unit_path="$HOME/.config/systemd/user/airc.service" + if [ -f "$unit_path" ]; then + echo " Unit: $unit_path" + local active; active=$(systemctl --user is-active airc.service 2>/dev/null) + local enabled; enabled=$(systemctl --user is-enabled airc.service 2>/dev/null) + echo " Active: $active" + echo " Enabled: $enabled" + local scope; scope=$(_daemon_scope) + echo " Logs: $scope/daemon.log (journalctl --user -u airc -f for live)" + else + echo " No daemon installed. Run: airc daemon install" + fi + ;; + windows) + local entry_name="airc-monitor" + if reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" //v "$entry_name" >/dev/null 2>&1; then + echo " Type: HKCU Run-key (per-user logon autostart, no admin)" + echo " Entry: $entry_name" + local scope; scope=$(_daemon_scope) + echo " Logs: $scope/daemon.log" + echo " Errors: $scope/daemon.err" + echo " Launcher: $scope/airc-daemon.bat" + # Is the daemon-launched airc actually running right now? The + # launcher .bat spawns bash + airc-connect then exits, so we + # look for the airc-connect process (PPID=1 = orphaned-into- + # init, which is what `start /B` produces on Windows). Falling + # back to airc.pid lookup if that fails. + local live_pid + live_pid=$(ps -ef 2>/dev/null | awk '$3 == 1 && /airc.*connect/ && !/grep/ {print $2; exit}') + if [ -z "$live_pid" ] && [ -f "$scope/airc.pid" ]; then + local pidfile_pid + pidfile_pid=$(head -1 "$scope/airc.pid" 2>/dev/null | tr -d '[:space:]') + if [ -n "$pidfile_pid" ] && kill -0 "$pidfile_pid" 2>/dev/null; then + live_pid="$pidfile_pid (from airc.pid)" + fi + fi + if [ -n "$live_pid" ]; then + echo " Status: RUNNING (PID $live_pid)" + else + echo " Status: registered (will start at next logon — or 'airc daemon install' to start now)" + fi + else + echo " No daemon installed. Run: airc daemon install" + fi + ;; + *) echo " Daemon status not supported on $(uname -s)." ;; + esac +} + +cmd_daemon_log() { + local n="${1:-50}" + local scope; scope=$(_daemon_scope) + local log="$scope/daemon.log" + if [ ! -f "$log" ]; then + echo " No log at $log. Daemon may not have started yet." + return 1 + fi + tail -"$n" "$log" +}