aidevops 3.13.0 → 3.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -238,6 +238,23 @@ This creates:
238
238
 
239
239
  **Available features:** `planning`, `git-workflow`, `code-quality`, `time-tracking`, `beads`
240
240
 
241
+ ### Per-repo platform setup
242
+
243
+ After `aidevops init` registers a new repo, run `/setup-git` in your AI assistant
244
+ to apply per-repo platform secrets. Most notably, this sets `SYNC_PAT` — a
245
+ GitHub Actions secret that lets `issue-sync.yml` push TODO.md auto-completion
246
+ past branch protection.
247
+
248
+ This is distinct from `/onboarding` (per-account credentials like `gh auth login`):
249
+ GitHub Actions secrets are scoped per-repo, so each repo needs its own. You need
250
+ `gh auth login` to succeed before any per-repo helper can run, so `/onboarding`
251
+ comes first, `/setup-git` second.
252
+
253
+ Run `/setup-git` again whenever you register a new repo with `aidevops repos add`
254
+ or when a `SYNC_PAT` advisory appears in the session greeting toast. If you skip
255
+ this step, `issue-sync.yml` will post a remediation comment when it hits branch
256
+ protection — `/setup-git` walks through the fix.
257
+
241
258
  ### Upgrade Planning Files
242
259
 
243
260
  When aidevops templates evolve, upgrade existing projects to the latest format:
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.13.0
1
+ 3.13.1
package/aidevops.sh CHANGED
@@ -5,7 +5,7 @@
5
5
  # AI DevOps Framework CLI
6
6
  # Usage: aidevops <command> [options]
7
7
  #
8
- # Version: 3.13.0
8
+ # Version: 3.13.1
9
9
 
10
10
  set -euo pipefail
11
11
 
@@ -557,6 +557,71 @@ _update_verify_signature() {
557
557
  return 0
558
558
  }
559
559
 
560
+ # One-shot, idempotent migration of supervisor.* → orchestration.* in settings.json (t2946).
561
+ # Safe: reads value from supervisor.* only when orchestration.* key is absent.
562
+ # Logs to ~/.aidevops/logs/settings-migration.log.
563
+ _migrate_settings_supervisor_to_orchestration() {
564
+ local _settings_file="${HOME}/.config/aidevops/settings.json"
565
+ local _log_file="${HOME}/.aidevops/logs/settings-migration.log"
566
+
567
+ if ! command -v jq >/dev/null 2>&1; then
568
+ return 0
569
+ fi
570
+ if [[ ! -f "$_settings_file" ]]; then
571
+ return 0
572
+ fi
573
+ if ! jq . "$_settings_file" >/dev/null 2>&1; then
574
+ return 0
575
+ fi
576
+
577
+ # Check if supervisor.pulse_interval_seconds exists and orchestration.pulse_interval_seconds is absent.
578
+ local _has_sv _has_orch
579
+ _has_sv=$(jq -r 'if .supervisor.pulse_interval_seconds != null then "yes" else "no" end' "$_settings_file" 2>/dev/null)
580
+ _has_orch=$(jq -r 'if .orchestration.pulse_interval_seconds != null then "yes" else "no" end' "$_settings_file" 2>/dev/null)
581
+
582
+ if [[ "$_has_sv" != "yes" ]]; then
583
+ return 0
584
+ fi
585
+
586
+ local _ts
587
+ _ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date +%Y-%m-%dT%H:%M:%SZ)
588
+ mkdir -p "$(dirname "$_log_file")" 2>/dev/null || true
589
+
590
+ local _tmp
591
+ _tmp=$(mktemp 2>/dev/null) || return 0
592
+
593
+ if [[ "$_has_orch" == "no" ]]; then
594
+ # Migrate: copy supervisor.pulse_interval_seconds to orchestration.pulse_interval_seconds,
595
+ # then remove supervisor.pulse_interval_seconds.
596
+ local _sv_val
597
+ _sv_val=$(jq -r '.supervisor.pulse_interval_seconds' "$_settings_file" 2>/dev/null)
598
+ if jq --argjson v "$_sv_val" \
599
+ '(.orchestration.pulse_interval_seconds) = $v | del(.supervisor.pulse_interval_seconds)' \
600
+ "$_settings_file" >"$_tmp" 2>/dev/null && [[ -s "$_tmp" ]]; then
601
+ mv "$_tmp" "$_settings_file"
602
+ printf '[%s] migrated supervisor.pulse_interval_seconds=%s → orchestration.pulse_interval_seconds\n' \
603
+ "$_ts" "$_sv_val" >>"$_log_file" 2>/dev/null || true
604
+ print_info "Settings migrated: supervisor.pulse_interval_seconds → orchestration.pulse_interval_seconds ($_sv_val)"
605
+ else
606
+ rm -f "$_tmp"
607
+ fi
608
+ else
609
+ # Both present: orchestration wins, remove the stale supervisor key.
610
+ local _orch_val
611
+ _orch_val=$(jq -r '.orchestration.pulse_interval_seconds' "$_settings_file" 2>/dev/null)
612
+ if jq 'del(.supervisor.pulse_interval_seconds)' \
613
+ "$_settings_file" >"$_tmp" 2>/dev/null && [[ -s "$_tmp" ]]; then
614
+ mv "$_tmp" "$_settings_file"
615
+ printf '[%s] removed stale supervisor.pulse_interval_seconds (orchestration.pulse_interval_seconds=%s wins)\n' \
616
+ "$_ts" "$_orch_val" >>"$_log_file" 2>/dev/null || true
617
+ print_info "Settings cleaned: removed stale supervisor.pulse_interval_seconds (orchestration value $_orch_val kept)"
618
+ else
619
+ rm -f "$_tmp"
620
+ fi
621
+ fi
622
+ return 0
623
+ }
624
+
560
625
  # Update/upgrade command
561
626
  cmd_update() {
562
627
  local skip_project_sync=false
@@ -693,6 +758,12 @@ cmd_update() {
693
758
  _update_check_daemon_health
694
759
  fi
695
760
 
761
+ # t2946: one-shot idempotent migration from legacy supervisor.* to canonical
762
+ # orchestration.* namespace in settings.json. Safe: no-op when orchestration.*
763
+ # is already set. Runs even on "already up to date" updates so users who
764
+ # install the fix without a new release still get migrated on next 'aidevops update'.
765
+ _migrate_settings_supervisor_to_orchestration
766
+
696
767
  # t2914: ensure pulse is running after every update. The existing
697
768
  # restart paths (setup.sh:1329, agent-deploy.sh:601) call
698
769
  # pulse-lifecycle-helper.sh restart-if-running which is a silent no-op
@@ -708,7 +779,7 @@ cmd_update() {
708
779
  # 'restart-if-running' do). Non-fatal: a pulse start failure should
709
780
  # not fail the update.
710
781
  if [[ "${AIDEVOPS_SKIP_PULSE_RESTART:-0}" != "1" ]]; then
711
- local _pulse_helper="${HOME}/.aidevops/agents/scripts/pulse-lifecycle-helper.sh"
782
+ local _pulse_helper="${AGENTS_DIR}/scripts/pulse-lifecycle-helper.sh"
712
783
  if [[ -x "$_pulse_helper" ]]; then
713
784
  "$_pulse_helper" start >/dev/null 2>&1 || print_warning "Pulse start failed (non-fatal)"
714
785
  fi
@@ -1496,6 +1567,7 @@ _help_commands() {
1496
1567
  echo " client-format Client request format alignment (extract/check/canary/monitor)"
1497
1568
  echo " opencode-sandbox Test OpenCode versions in isolation (install/run/check/clean)"
1498
1569
  echo " approve <cmd> Cryptographic issue/PR approval (setup/issue/pr/verify/status)"
1570
+ echo " issue <cmd> Interactive issue ownership (claim/release/status/scan-stale)"
1499
1571
  echo " security [cmd] Full security assessment (posture + hygiene + supply chain)"
1500
1572
  echo " contributions External contributions inbox (bare: status | seed/scan/stop/restart/install/uninstall)"
1501
1573
  echo " inbox [cmd] Capture transit zone (bare: status | provision/add/find/digest/help)"
@@ -1505,6 +1577,7 @@ _help_commands() {
1505
1577
  echo " secret <cmd> Manage secrets (set/list/run/init/import/status)"
1506
1578
  echo " config <cmd> Feature toggles (list/get/set/reset/path/help)"
1507
1579
  echo " knowledge <cmd> Knowledge plane management (init/status/provision)"
1580
+ echo " campaign <cmd> Campaign plane P6: launch + promote results/learnings"
1508
1581
  echo " stats <cmd> LLM usage analytics (summary/models/projects/costs/trend)"
1509
1582
  echo " tabby <cmd> Manage Tabby terminal profiles (sync/status/zshrc/help)"
1510
1583
  echo " parent-status <N> Show decomposition state of parent-task issue #N (alias: ps)"
@@ -1590,6 +1663,15 @@ _help_detailed_sections() {
1590
1663
  echo " aidevops knowledge init off # Disable knowledge plane"
1591
1664
  echo " aidevops knowledge status # Show provisioning state"
1592
1665
  echo " aidevops knowledge provision [path] # Re-provision (idempotent)"
1666
+ echo " aidevops knowledge add <file|url> # Ingest file or URL into sources/"
1667
+ echo " aidevops knowledge list [--state s] [--kind k] # List all known sources"
1668
+ echo " aidevops knowledge search <query> # Search sources (grep fallback)"
1669
+ echo ""
1670
+ echo "Campaign Plane (P6 — performance integration + learnings promotion):"
1671
+ echo " aidevops campaign launch <id> # Move active/<id> → launched/, create result/learning templates"
1672
+ echo " aidevops campaign promote <id> --results # Push metrics to _performance/marketing/<id>.md"
1673
+ echo " aidevops campaign promote <id> --learnings # Promote insights to _knowledge/insights/marketing/"
1674
+ echo " aidevops campaign feedback [<id>] # Surface _feedback/ insights for campaign research"
1593
1675
  echo ""
1594
1676
  echo "LLM Stats:"
1595
1677
  echo " aidevops stats # Show usage summary (last 30 days)"
@@ -1935,6 +2017,75 @@ main() {
1935
2017
  pulse) _dispatch_helper "pulse-session-helper.sh" "pulse-session-helper.sh" "$@" ;;
1936
2018
  check-workflows | workflows) _dispatch_helper "check-workflows-helper.sh" "check-workflows-helper.sh" "$@" ;;
1937
2019
  sync-workflows) _dispatch_helper "sync-workflows-helper.sh" "sync-workflows-helper.sh" "$@" ;;
2020
+ badges)
2021
+ # Badge management: render | check | sync | install (t2975)
2022
+ # Bare 'aidevops badges' with no subcommand shows a usage summary.
2023
+ # Subcommands:
2024
+ # render <slug> — render canonical badge block for a repo
2025
+ # check [--repo SLUG] [--json] [--verbose] — cross-repo drift check
2026
+ # sync [--repo SLUG] [--apply] — inject badge block + install workflow
2027
+ # install [--repo SLUG] [--apply] — install loc-badge caller workflow only
2028
+ local _badges_sub="${1:-help}"
2029
+ local _badges_check_h="badges-check-helper.sh"
2030
+ local _badges_sync_h="badges-sync-helper.sh"
2031
+ case "$_badges_sub" in
2032
+ render)
2033
+ shift
2034
+ local _render_helper
2035
+ _render_helper=$(bash -c '
2036
+ d="$HOME/.aidevops/agents/scripts/readme-badges-helper.sh"
2037
+ l="'"$AGENTS_DIR"'/scripts/readme-badges-helper.sh"
2038
+ [[ -f "$d" ]] && echo "$d" || echo "$l"
2039
+ ')
2040
+ if [[ -f "$_render_helper" ]]; then
2041
+ bash "$_render_helper" render "$@"
2042
+ else
2043
+ print_error "readme-badges-helper.sh not found. Run: aidevops update"
2044
+ exit 1
2045
+ fi
2046
+ ;;
2047
+ check)
2048
+ shift
2049
+ _dispatch_helper "$_badges_check_h" "$_badges_check_h" "$@"
2050
+ ;;
2051
+ sync)
2052
+ shift
2053
+ _dispatch_helper "$_badges_sync_h" "$_badges_sync_h" "$@"
2054
+ ;;
2055
+ install)
2056
+ shift
2057
+ _dispatch_helper "$_badges_sync_h" "$_badges_sync_h" --workflow-only "$@"
2058
+ ;;
2059
+ help | --help | -h | "")
2060
+ echo ""
2061
+ echo "aidevops badges — README badge block and LOC workflow management (t2975)"
2062
+ echo ""
2063
+ echo "Subcommands:"
2064
+ echo " render <slug> Print canonical badge block for a repo"
2065
+ echo " check [--repo SLUG] [--json] Detect badge drift across managed repos"
2066
+ echo " sync [--repo SLUG] [--apply] Inject badge block + install LOC workflow"
2067
+ echo " install [--repo SLUG] [--apply] Install loc-badge caller workflow only"
2068
+ echo ""
2069
+ echo "Options (check/sync/install):"
2070
+ echo " --repo SLUG Limit to a single repo"
2071
+ echo " --apply Actually perform the sync (default: dry-run)"
2072
+ echo " --json Machine-readable output"
2073
+ echo " --verbose Show diff summaries (check only)"
2074
+ echo ""
2075
+ echo "Examples:"
2076
+ echo " aidevops badges check # scan all repos for badge drift"
2077
+ echo " aidevops badges check --json | jq '.[]' # machine-readable output"
2078
+ echo " aidevops badges render owner/repo # print badge block"
2079
+ echo " aidevops badges sync # dry-run sync across all repos"
2080
+ echo " aidevops badges sync --repo owner/r --apply # apply to a single repo"
2081
+ echo ""
2082
+ ;;
2083
+ *)
2084
+ print_error "Unknown badges subcommand: $_badges_sub (try render|check|sync|install|help)"
2085
+ exit 1
2086
+ ;;
2087
+ esac
2088
+ ;;
1938
2089
  security) _cmd_security "$@" ;;
1939
2090
  doctor | doc) _dispatch_helper "doctor-helper.sh" "doctor-helper.sh" "$@" ;;
1940
2091
  detect | scan) cmd_detect ;;
@@ -1945,6 +2096,7 @@ main() {
1945
2096
  review-gate | review_gate) _dispatch_helper "review-gate-config-helper.sh" "review-gate-config-helper.sh" "$@" ;;
1946
2097
  secret | secrets) _dispatch_helper "secret-helper.sh" "secret-helper.sh" "$@" ;;
1947
2098
  approve) _dispatch_helper "approval-helper.sh" "approval-helper.sh" "$@" ;;
2099
+ issue) _dispatch_helper "interactive-session-helper.sh" "interactive-session-helper.sh" "$@" ;;
1948
2100
  signing) _dispatch_helper "signing-setup.sh" "signing-setup.sh" "$@" ;;
1949
2101
  contributions | contrib)
1950
2102
  # Bare `aidevops contributions` defaults to status (most common use).
@@ -1960,7 +2112,13 @@ main() {
1960
2112
  case | cases)
1961
2113
  # Bare `aidevops case` defaults to list (most common use).
1962
2114
  [[ $# -eq 0 ]] && set -- list
1963
- _dispatch_helper "case-helper.sh" "case-helper.sh" "$@"
2115
+ # alarm-test subcommand routes to case-alarm-helper.sh
2116
+ if [[ "${1:-}" == "alarm-test" ]]; then
2117
+ shift
2118
+ _dispatch_helper "case-alarm-helper.sh" "case-alarm-helper.sh" alarm-test "$@"
2119
+ else
2120
+ _dispatch_helper "case-helper.sh" "case-helper.sh" "$@"
2121
+ fi
1964
2122
  ;;
1965
2123
  email) _cmd_email "$@" ;;
1966
2124
  stats | observability) _dispatch_helper "observability-helper.sh" "observability-helper.sh" "$@" ;;
@@ -1968,6 +2126,7 @@ main() {
1968
2126
  init-routines) _dispatch_helper "init-routines-helper.sh" "init-routines-helper.sh" "$@" ;;
1969
2127
  parent-status | ps) _dispatch_helper "parent-status-helper.sh" "parent-status-helper.sh" "$@" ;;
1970
2128
  knowledge) _dispatch_helper "knowledge-helper.sh" "knowledge-helper.sh" "$@" ;;
2129
+ campaign | campaigns) _dispatch_helper "campaign-helper.sh" "campaign-helper.sh" "$@" ;;
1971
2130
  config | configure) _dispatch_config "$@" ;;
1972
2131
  uninstall | remove) cmd_uninstall ;;
1973
2132
  version | v | -v | --version) cmd_version ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aidevops",
3
- "version": "3.13.0",
3
+ "version": "3.13.1",
4
4
  "description": "AI DevOps Framework - AI-assisted development workflows, code quality, and deployment automation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,386 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-License-Identifier: MIT
3
+ # SPDX-FileCopyrightText: 2025-2026 Marcus Quinn
4
+ # =============================================================================
5
+ # Schedulers Linux Sub-Library -- systemd/cron scheduler installation and
6
+ # uninstall functions for Linux (and macOS uninstall path).
7
+ # =============================================================================
8
+ # This sub-library is sourced by setup-modules/schedulers.sh (the orchestrator).
9
+ # It covers:
10
+ # - systemd user service availability check
11
+ # - systemd value escaping
12
+ # - Building systemd Environment= and cron env prefix lines
13
+ # - Generic systemd timer installation
14
+ # - Generic cron entry installation
15
+ # - Linux dispatcher (systemd preferred, cron fallback)
16
+ # - Generic scheduler uninstall (launchd/systemd/cron)
17
+ # - Supervisor pulse uninstall
18
+ #
19
+ # Usage: source "${SCRIPT_DIR}/schedulers-linux.sh"
20
+ #
21
+ # Dependencies:
22
+ # - shared-constants.sh (print_info, print_warning)
23
+ #
24
+ # Part of aidevops framework: https://aidevops.sh
25
+
26
+ # Apply strict mode only when executed directly (not when sourced)
27
+ [[ "${BASH_SOURCE[0]}" == "${0}" ]] && set -euo pipefail
28
+
29
+ # Include guard
30
+ [[ -n "${_SCHEDULERS_LINUX_LIB_LOADED:-}" ]] && return 0
31
+ _SCHEDULERS_LINUX_LIB_LOADED=1
32
+
33
+ # SCRIPT_DIR fallback — needed when sourced from test harnesses that don't set it.
34
+ if [[ -z "${SCRIPT_DIR:-}" ]]; then
35
+ _sched_linux_lib_path="${BASH_SOURCE[0]%/*}"
36
+ [[ "$_sched_linux_lib_path" == "${BASH_SOURCE[0]}" ]] && _sched_linux_lib_path="."
37
+ SCRIPT_DIR="$(cd "$_sched_linux_lib_path" && pwd)"
38
+ unset _sched_linux_lib_path
39
+ fi
40
+
41
+ # --- Functions ---
42
+
43
+ # Check if systemd user services are available on this Linux system.
44
+ # Returns 0 if systemd --user is functional, 1 otherwise.
45
+ _systemd_user_available() {
46
+ command -v systemctl >/dev/null 2>&1 || return 1
47
+ systemctl --user status >/dev/null 2>&1 || return 1
48
+ return 0
49
+ }
50
+
51
+ # Escape a value for safe embedding in a systemd unit Environment= or ExecStart=
52
+ # directive. systemd interprets % as specifiers (%h, %n, %t, etc.) and spaces
53
+ # as key-value separators. This helper:
54
+ # 1. Escapes \ → \\ (must be first to avoid double-escaping)
55
+ # 2. Doubles % → %% (escape specifiers)
56
+ # 3. Escapes embedded " → \"
57
+ # 4. Wraps the result in "..." (handles spaces and other shell metacharacters)
58
+ # Usage: escaped=$(_systemd_escape "$value")
59
+ #
60
+ # WARNING: Do NOT use for StandardOutput= or StandardError= directives.
61
+ # systemd does not strip outer quotes from those values — "append:/path" is
62
+ # treated as a literal filename with quote characters, failing silently.
63
+ # Use bare values for StandardOutput=/StandardError=:
64
+ # StandardOutput=append:${log_file} ← correct
65
+ # StandardOutput=$(_systemd_escape "append:${log_file}") ← WRONG
66
+ _systemd_escape() {
67
+ local _val="$1"
68
+ # Step 1: escape backslashes
69
+ _val="${_val//\\/\\\\}"
70
+ # Step 2: escape % specifiers
71
+ _val="${_val//%/%%}"
72
+ # Step 3: escape embedded double-quotes
73
+ _val="${_val//\"/\\\"}"
74
+ # Step 4: wrap in double-quotes
75
+ printf '"%s"' "$_val"
76
+ return 0
77
+ }
78
+
79
+ # Build systemd Environment= lines from newline-separated KEY=VALUE pairs.
80
+ # Always appends HOME and PATH for parity with launchd and cron execution.
81
+ _scheduler_systemd_env_lines() {
82
+ local env_vars="$1"
83
+ local _env_lines=""
84
+
85
+ if [[ -n "$env_vars" ]]; then
86
+ while IFS= read -r _kv; do
87
+ [[ -z "$_kv" ]] && continue
88
+ local _key="${_kv%%=*}"
89
+ local _raw_val="${_kv#*=}"
90
+ local _escaped_val
91
+ _escaped_val=$(_systemd_escape "$_raw_val")
92
+ _env_lines+="Environment=${_key}=${_escaped_val}"$'\n'
93
+ done <<<"$env_vars"
94
+ fi
95
+
96
+ _env_lines+="Environment=HOME=$(_systemd_escape "$HOME")"$'\n'
97
+ _env_lines+="Environment=PATH=$(_systemd_escape "$PATH")"$'\n'
98
+ printf '%s' "$_env_lines"
99
+ return 0
100
+ }
101
+
102
+ # Build inline cron environment assignments from newline-separated KEY=VALUE pairs.
103
+ _scheduler_cron_env_prefix() {
104
+ local env_vars="$1"
105
+ local _env_prefix=""
106
+
107
+ if [[ -n "$env_vars" ]]; then
108
+ while IFS= read -r _kv; do
109
+ [[ -z "$_kv" ]] && continue
110
+ local _key="${_kv%%=*}"
111
+ local _raw_val="${_kv#*=}"
112
+ local _escaped_val
113
+ _escaped_val=$(_cron_escape "$_raw_val")
114
+ _env_prefix+="${_key}=${_escaped_val} "
115
+ done <<<"$env_vars"
116
+ fi
117
+
118
+ printf '%s' "$_env_prefix"
119
+ return 0
120
+ }
121
+
122
+ # Install a generic scheduler via systemd user timer (Linux with systemd).
123
+ # Args:
124
+ # $1 = service_name (e.g. "aidevops-stats-wrapper")
125
+ # $2 = exec_command (shell command run via /bin/bash -lc)
126
+ # $3 = interval_sec (OnUnitActiveSec interval in seconds; may be empty for calendar-only)
127
+ # $4 = log_file (absolute path to log file)
128
+ # $5 = env_vars (newline-separated KEY=VALUE pairs, may be empty)
129
+ # $6 = run_at_load ("true" or "false")
130
+ # $7 = low_priority ("true" or "false")
131
+ # $8 = on_calendar (optional systemd OnCalendar spec)
132
+ # $9 = timeout_sec (optional TimeoutStartSec; defaults to interval_sec)
133
+ # Returns 0 on success, 1 if systemd enable fails (caller should fall back to cron).
134
+ _install_scheduler_systemd() {
135
+ local service_name="$1"
136
+ local exec_command="$2"
137
+ local interval_sec="$3"
138
+ local log_file="$4"
139
+ local env_vars="$5"
140
+ local run_at_load="$6"
141
+ local low_priority="$7"
142
+ local on_calendar="$8"
143
+ local timeout_sec="$9"
144
+ local service_dir="$HOME/.config/systemd/user"
145
+ local service_file="${service_dir}/${service_name}.service"
146
+ local timer_file="${service_dir}/${service_name}.timer"
147
+
148
+ mkdir -p "$service_dir"
149
+
150
+ # GH#18439 Bug 1: command substitution strips trailing newlines, which
151
+ # would run the final Environment=PATH=... into the following
152
+ # StandardOutput=... directive on the same line. Use a sentinel ('x')
153
+ # to preserve the trailing newline that _scheduler_systemd_env_lines
154
+ # always emits.
155
+ local _env_lines
156
+ _env_lines=$(
157
+ _scheduler_systemd_env_lines "$env_vars"
158
+ printf 'x'
159
+ )
160
+ _env_lines="${_env_lines%x}"
161
+
162
+ if [[ -z "$timeout_sec" ]]; then
163
+ timeout_sec="$interval_sec"
164
+ fi
165
+ if [[ -z "$timeout_sec" ]]; then
166
+ timeout_sec="3600"
167
+ fi
168
+
169
+ local _service_extra=""
170
+ if [[ "$low_priority" == "true" ]]; then
171
+ _service_extra+="Nice=10"$'\n'
172
+ _service_extra+="IOSchedulingClass=idle"$'\n'
173
+ fi
174
+
175
+ printf '%s' "[Unit]
176
+ Description=aidevops ${service_name}
177
+ After=network.target
178
+
179
+ [Service]
180
+ Type=oneshot
181
+ KillMode=process
182
+ ExecStart=/bin/bash -lc $(_systemd_escape "$exec_command")
183
+ TimeoutStartSec=${timeout_sec}
184
+ ${_service_extra}${_env_lines}StandardOutput=append:${log_file}
185
+ StandardError=append:${log_file}
186
+ " >"$service_file"
187
+
188
+ local _timer_lines=""
189
+ if [[ "$run_at_load" == "true" ]]; then
190
+ _timer_lines+="OnActiveSec=10s"$'\n'
191
+ fi
192
+ if [[ -n "$interval_sec" ]]; then
193
+ _timer_lines+="OnBootSec=${interval_sec}"$'\n'
194
+ _timer_lines+="OnUnitActiveSec=${interval_sec}"$'\n'
195
+ fi
196
+ if [[ -n "$on_calendar" ]]; then
197
+ _timer_lines+="OnCalendar=${on_calendar}"$'\n'
198
+ fi
199
+
200
+ printf '%s' "[Unit]
201
+ Description=aidevops ${service_name} Timer
202
+
203
+ [Timer]
204
+ ${_timer_lines}Persistent=true
205
+
206
+ [Install]
207
+ WantedBy=timers.target
208
+ " >"$timer_file"
209
+
210
+ systemctl --user daemon-reload 2>/dev/null || true
211
+ if systemctl --user enable --now "${service_name}.timer" 2>/dev/null; then
212
+ return 0
213
+ fi
214
+ return 1
215
+ }
216
+
217
+ # Install a generic cron entry.
218
+ # Args: $1=cron_tag, $2=cron_schedule, $3=exec_command, $4=log_file, $5=env_vars
219
+ _install_scheduler_cron() {
220
+ local cron_tag="$1"
221
+ local cron_schedule="$2"
222
+ local exec_command="$3"
223
+ local log_file="$4"
224
+ local env_vars="$5"
225
+ local _cron_exec
226
+ local _cron_log
227
+ local _env_prefix
228
+
229
+ _env_prefix=$(_scheduler_cron_env_prefix "$env_vars")
230
+ _cron_exec=$(_cron_escape "$exec_command")
231
+ _cron_log=$(_cron_escape "$log_file")
232
+
233
+ (
234
+ crontab -l 2>/dev/null | grep -vF "${cron_tag}" || true
235
+ echo "${cron_schedule} ${_env_prefix}/bin/bash -lc ${_cron_exec} >> ${_cron_log} 2>&1 # ${cron_tag}"
236
+ ) | crontab - 2>/dev/null || true
237
+ return 0
238
+ }
239
+
240
+ # Dispatcher: install a scheduler on Linux, preferring systemd over cron.
241
+ # Args:
242
+ # $1 = service_name (systemd service name, e.g. "aidevops-stats-wrapper")
243
+ # $2 = cron_tag (comment tag for cron line, e.g. "aidevops: stats-wrapper")
244
+ # $3 = cron_schedule (cron schedule expression, e.g. "*/15 * * * *")
245
+ # $4 = exec_command (shell command run via /bin/bash -lc)
246
+ # $5 = interval_sec (systemd OnUnitActiveSec in seconds; may be empty for calendar-only)
247
+ # $6 = log_file (absolute path to log file)
248
+ # $7 = env_vars (newline-separated KEY=VALUE pairs for systemd/cron, may be empty)
249
+ # $8 = success_msg (message to print on success)
250
+ # $9 = fail_msg (message to print on failure)
251
+ # $10 = run_at_load ("true" or "false")
252
+ # $11 = low_priority ("true" or "false")
253
+ # $12 = on_calendar (optional systemd OnCalendar spec)
254
+ # $13 = timeout_sec (optional TimeoutStartSec)
255
+ # Returns 0 always (failures are warnings, not fatal).
256
+ _install_scheduler_linux() {
257
+ local service_name="$1"
258
+ local cron_tag="$2"
259
+ local cron_schedule="$3"
260
+ local exec_command="$4"
261
+ local interval_sec="$5"
262
+ local log_file="$6"
263
+ local env_vars="$7"
264
+ local success_msg="$8"
265
+ local fail_msg="$9"
266
+ local run_at_load="${10}"
267
+ local low_priority="${11}"
268
+ local on_calendar="${12:-}"
269
+ local timeout_sec="${13:-}"
270
+
271
+ if _systemd_user_available; then
272
+ if _install_scheduler_systemd \
273
+ "$service_name" \
274
+ "$exec_command" \
275
+ "$interval_sec" \
276
+ "$log_file" \
277
+ "$env_vars" \
278
+ "$run_at_load" \
279
+ "$low_priority" \
280
+ "$on_calendar" \
281
+ "$timeout_sec"; then
282
+ print_info "${success_msg} (systemd user timer)"
283
+ # After systemd install succeeds, remove any pre-existing cron entry
284
+ # to prevent dual-execution (GH#17695 Finding A)
285
+ if command -v crontab >/dev/null 2>&1; then
286
+ local current_cron
287
+ current_cron=$(crontab -l 2>/dev/null) || current_cron=""
288
+ if [[ -n "$current_cron" ]] && echo "$current_cron" | grep -qF "$cron_tag"; then
289
+ echo "$current_cron" | grep -vF "$cron_tag" | crontab -
290
+ echo "[schedulers] Removed pre-existing cron entry for $cron_tag (migrated to systemd)"
291
+ fi
292
+ fi
293
+ else
294
+ print_warning "systemd enable failed for ${service_name} — falling back to cron"
295
+ _install_scheduler_cron "$cron_tag" "$cron_schedule" "$exec_command" "$log_file" "$env_vars"
296
+ if crontab -l 2>/dev/null | grep -qF "${cron_tag}" 2>/dev/null; then
297
+ print_info "${success_msg} (cron fallback)"
298
+ else
299
+ print_warning "${fail_msg}"
300
+ fi
301
+ fi
302
+ else
303
+ _install_scheduler_cron "$cron_tag" "$cron_schedule" "$exec_command" "$log_file" "$env_vars"
304
+ if crontab -l 2>/dev/null | grep -qF "${cron_tag}" 2>/dev/null; then
305
+ print_info "${success_msg} (cron)"
306
+ else
307
+ print_warning "${fail_msg}"
308
+ fi
309
+ fi
310
+ return 0
311
+ }
312
+
313
+ # Uninstall a scheduler across all backends (launchd/systemd/cron).
314
+ # Args:
315
+ # $1 = os (output of uname -s)
316
+ # $2 = launchd_label (e.g. "sh.aidevops.stats-wrapper")
317
+ # $3 = systemd_name (e.g. "aidevops-stats-wrapper")
318
+ # $4 = cron_tag (grep pattern for cron line, e.g. "aidevops: stats-wrapper")
319
+ # $5 = success_msg (message to print on removal)
320
+ # Returns 0 always.
321
+ _uninstall_scheduler() {
322
+ local _os="$1"
323
+ local launchd_label="$2"
324
+ local systemd_name="$3"
325
+ local cron_tag="$4"
326
+ local success_msg="$5"
327
+
328
+ if [[ "$_os" == "Darwin" ]]; then
329
+ local _plist="$HOME/Library/LaunchAgents/${launchd_label}.plist"
330
+ if _launchd_has_agent "$launchd_label"; then
331
+ launchctl unload "$_plist" 2>/dev/null || true
332
+ rm -f "$_plist"
333
+ print_info "${success_msg} (launchd agent removed)"
334
+ fi
335
+ else
336
+ # Check and remove from ALL backends sequentially, not just the first
337
+ # match. Prevents orphan entries when migrating between systemd and cron
338
+ # (GH#17695 Finding A).
339
+ if _systemd_user_available && systemctl --user is-enabled "${systemd_name}.timer" >/dev/null 2>&1; then
340
+ systemctl --user disable --now "${systemd_name}.timer" 2>/dev/null || true
341
+ rm -f "$HOME/.config/systemd/user/${systemd_name}.service"
342
+ rm -f "$HOME/.config/systemd/user/${systemd_name}.timer"
343
+ systemctl --user daemon-reload 2>/dev/null || true
344
+ print_info "${success_msg} (systemd timer removed)"
345
+ fi
346
+ if command -v crontab >/dev/null 2>&1; then
347
+ local current_cron
348
+ current_cron=$(crontab -l 2>/dev/null) || current_cron=""
349
+ if [[ -n "$current_cron" ]] && echo "$current_cron" | grep -qF "${cron_tag}"; then
350
+ echo "$current_cron" | grep -vF "${cron_tag}" | crontab - 2>/dev/null || true
351
+ print_info "${success_msg} (cron entry removed)"
352
+ fi
353
+ fi
354
+ fi
355
+ return 0
356
+ }
357
+
358
+ # Uninstall supervisor pulse (user explicitly disabled)
359
+ _uninstall_pulse() {
360
+ local _os="$1"
361
+ local pulse_label="$2"
362
+ if [[ "$_os" == "Darwin" ]]; then
363
+ local pulse_plist="$HOME/Library/LaunchAgents/${pulse_label}.plist"
364
+ if _launchd_has_agent "$pulse_label"; then
365
+ launchctl unload "$pulse_plist" || true
366
+ rm -f "$pulse_plist"
367
+ pkill -f 'Supervisor Pulse' 2>/dev/null || true
368
+ print_info "Supervisor pulse disabled (launchd agent removed per config)"
369
+ fi
370
+ elif _systemd_user_available; then
371
+ local service_name="aidevops-supervisor-pulse"
372
+ if systemctl --user is-enabled "${service_name}.timer" >/dev/null 2>&1; then
373
+ systemctl --user disable --now "${service_name}.timer" 2>/dev/null || true
374
+ rm -f "$HOME/.config/systemd/user/${service_name}.service"
375
+ rm -f "$HOME/.config/systemd/user/${service_name}.timer"
376
+ systemctl --user daemon-reload 2>/dev/null || true
377
+ print_info "Supervisor pulse disabled (systemd timer removed per config)"
378
+ fi
379
+ else
380
+ if crontab -l 2>/dev/null | grep -qF "pulse-wrapper"; then
381
+ crontab -l 2>/dev/null | grep -v 'aidevops: supervisor-pulse' | crontab - || true
382
+ print_info "Supervisor pulse disabled (cron entry removed per config)"
383
+ fi
384
+ fi
385
+ return 0
386
+ }