aidevops 3.13.93 → 3.13.94

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/VERSION CHANGED
@@ -1 +1 @@
1
- 3.13.93
1
+ 3.13.94
@@ -31,6 +31,92 @@
31
31
  [[ -n "${_AIDEVOPS_INIT_LIB_LOADED:-}" ]] && return 0
32
32
  _AIDEVOPS_INIT_LIB_LOADED=1
33
33
 
34
+ _AGENT_SOURCE_TEMPLATE_VERSION="1"
35
+
36
+ _agent_source_template_dir() {
37
+ printf '%s\n' "${AGENTS_DIR}/templates/agent-source-repo"
38
+ return 0
39
+ }
40
+
41
+ _agent_source_apply_managed_template_file() {
42
+ local project_root="$1"
43
+ local relative_path="$2"
44
+ local template_dir dest src dest_dir
45
+ template_dir=$(_agent_source_template_dir)
46
+ src="$template_dir/$relative_path"
47
+ dest="$project_root/$relative_path"
48
+ dest_dir="${dest%/*}"
49
+
50
+ [[ -f "$src" ]] || return 1
51
+ [[ "$dest_dir" != "$dest" ]] && mkdir -p "$dest_dir"
52
+
53
+ if [[ ! -f "$dest" ]]; then
54
+ cp "$src" "$dest"
55
+ return 0
56
+ fi
57
+
58
+ if ! grep -q '<!-- aidevops:agent-source-template:start -->' "$dest" 2>/dev/null; then
59
+ return 0
60
+ fi
61
+ if ! grep -q '<!-- aidevops:agent-source-template:end -->' "$dest" 2>/dev/null; then
62
+ return 0
63
+ fi
64
+
65
+ python3 - "$dest" "$src" <<'PY'
66
+ from pathlib import Path
67
+ import sys
68
+
69
+ dest = Path(sys.argv[1])
70
+ src = Path(sys.argv[2])
71
+ start = "<!-- aidevops:agent-source-template:start -->"
72
+ end = "<!-- aidevops:agent-source-template:end -->"
73
+ old = dest.read_text()
74
+ new = src.read_text()
75
+ if start not in old or end not in old or start not in new or end not in new:
76
+ sys.exit(0)
77
+ old_prefix = old.split(start, 1)[0]
78
+ old_suffix = old.split(end, 1)[1]
79
+ new_block = start + new.split(start, 1)[1].split(end, 1)[0] + end
80
+ dest.write_text(old_prefix + new_block + old_suffix)
81
+ PY
82
+ return 0
83
+ }
84
+
85
+ seed_agent_source_repo_templates() {
86
+ local project_root="$1"
87
+ local template_dir
88
+ template_dir=$(_agent_source_template_dir)
89
+
90
+ if [[ ! -d "$template_dir" ]]; then
91
+ print_warning "Agent source template directory missing: $template_dir"
92
+ return 1
93
+ fi
94
+
95
+ local rel_dir
96
+ for rel_dir in \
97
+ ".agents" \
98
+ ".agents/tools" \
99
+ ".agents/services" \
100
+ ".agents/workflows" \
101
+ ".agents/reference" \
102
+ ".agents/scripts" \
103
+ ".agents/scripts/commands" \
104
+ ".agents/configs" \
105
+ ".agents/bundles" \
106
+ ".agents/templates" \
107
+ ".agents/rules" \
108
+ ".agents/tests" \
109
+ ".agents/custom" \
110
+ ".agents/draft"; do
111
+ mkdir -p "$project_root/$rel_dir"
112
+ done
113
+
114
+ _agent_source_apply_managed_template_file "$project_root" "AGENTS.md" || return 1
115
+ _agent_source_apply_managed_template_file "$project_root" ".agents/AGENTS.md" || return 1
116
+ print_success "Seeded agent-source repository templates (v${_AGENT_SOURCE_TEMPLATE_VERSION})"
117
+ return 0
118
+ }
119
+
34
120
  # Scaffold standard repo courtesy files if they don't exist
35
121
  # Scaffold helpers (extracted for complexity reduction)
36
122
  _scaffold_contributing() {
@@ -639,6 +725,12 @@ cmd_init() {
639
725
  init_scope=$(_infer_init_scope "$project_root")
640
726
  print_info "Init scope: $init_scope (controls which scaffolding files are created)"
641
727
 
728
+ local is_agent_source=false
729
+ if is_agent_source_repo "$project_root"; then
730
+ is_agent_source=true
731
+ print_info "Agent source repo: true (seeding core-style agent organization)"
732
+ fi
733
+
642
734
  # Create .aidevops.json config
643
735
  local config_file="$project_root/.aidevops.json"
644
736
  local aidevops_version
@@ -650,6 +742,7 @@ cmd_init() {
650
742
  "version": "$aidevops_version",
651
743
  "initialized": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
652
744
  "init_scope": "$init_scope",
745
+ "agent_source": $is_agent_source,
653
746
  "features": {
654
747
  "planning": $enable_planning,
655
748
  "git_workflow": $enable_git_workflow,
@@ -747,19 +840,23 @@ EOF
747
840
  # (symlinked) can see the aidevops main-agent slash commands.
748
841
  _init_scaffold_commands_symlinks "$project_root"
749
842
 
750
- # Scaffold or update .agents/AGENTS.md (idempotent — creates if missing,
751
- # updates Security section if file already exists)
752
- local _agents_md_existed=false
753
- [[ -f "$project_root/.agents/AGENTS.md" ]] && _agents_md_existed=true
754
- scaffold_agents_md "$project_root"
755
- if [[ "$_agents_md_existed" == "true" ]]; then
756
- print_success "Updated Security section in .agents/AGENTS.md"
843
+ if [[ "$is_agent_source" == "true" ]]; then
844
+ seed_agent_source_repo_templates "$project_root"
757
845
  else
758
- print_success "Created .agents/AGENTS.md"
846
+ # Scaffold or update .agents/AGENTS.md (idempotent — creates if missing,
847
+ # updates Security section if file already exists)
848
+ local _agents_md_existed=false
849
+ [[ -f "$project_root/.agents/AGENTS.md" ]] && _agents_md_existed=true
850
+ scaffold_agents_md "$project_root"
851
+ if [[ "$_agents_md_existed" == "true" ]]; then
852
+ print_success "Updated Security section in .agents/AGENTS.md"
853
+ else
854
+ print_success "Created .agents/AGENTS.md"
855
+ fi
759
856
  fi
760
857
 
761
858
  # Scaffold root AGENTS.md if missing
762
- if [[ ! -f "$project_root/AGENTS.md" ]]; then
859
+ if [[ "$is_agent_source" != "true" && ! -f "$project_root/AGENTS.md" ]]; then
763
860
  cat >"$project_root/AGENTS.md" <<ROOTAGENTSEOF
764
861
  # $repo_name
765
862
 
@@ -246,7 +246,61 @@ _scope_includes() {
246
246
  *) required_level=1 ;;
247
247
  esac
248
248
 
249
- [[ $current_level -ge $required_level ]]
249
+ if [[ $current_level -ge $required_level ]]; then
250
+ return 0
251
+ fi
252
+ return 1
253
+ }
254
+
255
+ # Check whether a repo is marked as an agent source repo in local project
256
+ # config or repos.json. Agent source repos use the same organization model as
257
+ # the core `.agents/` tree and receive safe template seeding/updating.
258
+ # Usage: is_agent_source_repo <project_root>
259
+ is_agent_source_repo() {
260
+ local project_root="$1"
261
+
262
+ if command -v jq &>/dev/null && [[ -f "$project_root/.aidevops.json" ]]; then
263
+ local project_flag
264
+ project_flag=$(jq -r 'if .agent_source == true or .role == "agent-source" then "true" else "false" end' "$project_root/.aidevops.json" 2>/dev/null || echo "false")
265
+ if [[ "$project_flag" == "true" ]]; then
266
+ return 0
267
+ fi
268
+ fi
269
+
270
+ if command -v jq &>/dev/null && [[ -f "${REPOS_FILE:-$HOME/.config/aidevops/repos.json}" ]]; then
271
+ local repos_file="${REPOS_FILE:-$HOME/.config/aidevops/repos.json}"
272
+ local canonical_path
273
+ canonical_path=$(cd "$project_root" 2>/dev/null && pwd -P) || canonical_path="$project_root"
274
+ local repo_flag
275
+ repo_flag=$(jq -r --arg path "$canonical_path" --arg raw_path "$project_root" '
276
+ .initialized_repos // []
277
+ | map(select(.path == $path or .path == $raw_path))
278
+ | if length > 0 and (.[0].agent_source == true or .[0].role == "agent-source") then "true" else "false" end
279
+ ' "$repos_file" 2>/dev/null || echo "false")
280
+ if [[ "$repo_flag" == "true" ]]; then
281
+ return 0
282
+ fi
283
+ fi
284
+
285
+ return 1
286
+ }
287
+
288
+ # Print registered repo paths marked as agent source repos.
289
+ # Usage: get_agent_source_repos
290
+ get_agent_source_repos() {
291
+ init_repos_file
292
+
293
+ if ! command -v jq &>/dev/null; then
294
+ return 0
295
+ fi
296
+
297
+ jq -r '
298
+ .initialized_repos // []
299
+ | .[]
300
+ | select(.agent_source == true or .role == "agent-source")
301
+ | .path // empty
302
+ ' "$REPOS_FILE" 2>/dev/null || true
303
+ return 0
250
304
  }
251
305
 
252
306
  # Resolve a worktree path to its canonical main-worktree path, if applicable.
@@ -644,4 +698,3 @@ check_protected_branch() {
644
698
  ;;
645
699
  esac
646
700
  }
647
-
@@ -63,6 +63,7 @@ _update_sync_projects() {
63
63
  [[ -z "$repo_path" ]] && continue
64
64
  [[ -d "$repo_path" ]] && check_repo_needs_upgrade "$repo_path" && repos_needing_upgrade+=("$repo_path")
65
65
  done < <(get_registered_repos)
66
+ _update_sync_agent_source_repos "$current_ver" || true
66
67
  if [[ ${#repos_needing_upgrade[@]} -eq 0 ]]; then
67
68
  print_success "All registered projects are up to date"
68
69
  return 0
@@ -95,6 +96,34 @@ _update_sync_projects() {
95
96
  return 0
96
97
  }
97
98
 
99
+ _update_sync_agent_source_repos() {
100
+ local current_ver="$1"
101
+ local synced=0 skipped=0 failed=0
102
+ local repo
103
+
104
+ while IFS= read -r repo; do
105
+ [[ -z "$repo" ]] && continue
106
+ if [[ ! -d "$repo" ]]; then
107
+ skipped=$((skipped + 1))
108
+ continue
109
+ fi
110
+ if seed_agent_source_repo_templates "$repo"; then
111
+ synced=$((synced + 1))
112
+ if [[ -f "$repo/.aidevops.json" ]] && command -v jq &>/dev/null; then
113
+ local temp_file="${repo}/.aidevops.json.tmp"
114
+ jq --arg version "$current_ver" '.version = $version | .agent_source = true' "$repo/.aidevops.json" >"$temp_file" 2>/dev/null && mv "$temp_file" "$repo/.aidevops.json" || rm -f "$temp_file"
115
+ fi
116
+ else
117
+ failed=$((failed + 1))
118
+ fi
119
+ done < <(get_agent_source_repos)
120
+
121
+ [[ $synced -gt 0 ]] && print_success "Synced $synced agent-source repo template(s)"
122
+ [[ $skipped -gt 0 ]] && print_info "Skipped $skipped unavailable agent-source repo(s)"
123
+ [[ $failed -gt 0 ]] && print_warning "$failed agent-source repo template sync(s) failed"
124
+ return 0
125
+ }
126
+
98
127
  _update_check_planning() {
99
128
  echo ""
100
129
  print_header "Checking Planning Templates"
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.93
8
+ # Version: 3.13.94
9
9
 
10
10
  set -euo pipefail
11
11
 
@@ -766,6 +766,7 @@ _help_commands() {
766
766
  echo " email [cmd] Email mailbox management (mailbox add/list/test/remove)"
767
767
  echo " ip-check <cmd> IP reputation checks (check/batch/report/providers)"
768
768
  echo " review-gate <cmd> Configure review_gate.rate_limit_behavior (list/set/unset)"
769
+ echo " github-app-auth GitHub App auth setup/status and API route decisions"
769
770
  echo " secret <cmd> Manage secrets (set/list/run/init/import/status)"
770
771
  echo " config <cmd> Feature toggles (list/get/set/reset/path/help)"
771
772
  echo " knowledge <cmd> Knowledge plane management (init/status/provision)"
@@ -842,6 +843,11 @@ _help_detailed_sections() {
842
843
  echo " aidevops secret import # Import from credentials.sh to gopass"
843
844
  echo " aidevops secret status # Show backend status"
844
845
  echo ""
846
+ echo "GitHub App Auth:"
847
+ echo " aidevops github-app-auth status --json # Show active auth mode and budgets"
848
+ echo " aidevops github-app-auth route issue-list # Explain route decision"
849
+ echo " aidevops github-app-auth rate-limit --json # Show cached per-pool budgets"
850
+ echo ""
845
851
  echo "Feature Toggles:"
846
852
  echo " aidevops config list # List all toggles with current values"
847
853
  echo " aidevops config get <key> # Get a toggle value"
@@ -1500,6 +1506,7 @@ main() {
1500
1506
  esac
1501
1507
  ;;
1502
1508
  client-format) _cmd_client_format "$@" ;;
1509
+ github-app-auth | github-app | gh-auth) _dispatch_helper "github-app-auth-helper.sh" "github-app-auth-helper.sh" "$@" ;;
1503
1510
  opencode-db | oc-db) _dispatch_helper "opencode-db-maintenance-helper.sh" "opencode-db-maintenance-helper.sh" "$@" ;;
1504
1511
  opencode-sandbox | oc-sandbox) _dispatch_helper "opencode-sandbox-helper.sh" "opencode-sandbox-helper.sh" "$@" ;;
1505
1512
  review-gate | review_gate) _dispatch_helper "review-gate-config-helper.sh" "review-gate-config-helper.sh" "$@" ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aidevops",
3
- "version": "3.13.93",
3
+ "version": "3.13.94",
4
4
  "description": "AI DevOps Framework - AI-assisted development workflows, code quality, and deployment automation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -355,6 +355,9 @@ _restore_latest_agents_backup() {
355
355
  local target_dir="$1"
356
356
  local backup_base="$HOME/.aidevops/agents-backups"
357
357
  local latest_backup=""
358
+ local parent_dir=""
359
+ local restore_staging=""
360
+ local old_dir=""
358
361
 
359
362
  if [[ ! -d "$backup_base" ]]; then
360
363
  print_warning "No agents backup directory found for restore: $backup_base"
@@ -368,14 +371,44 @@ _restore_latest_agents_backup() {
368
371
  fi
369
372
 
370
373
  print_warning "Restoring agents from latest backup: $latest_backup"
371
- rm -rf "$target_dir"
372
- mkdir -p "$(dirname "$target_dir")"
373
- if cp -a "$latest_backup" "$target_dir"; then
374
+ parent_dir=$(dirname "$target_dir")
375
+ mkdir -p "$parent_dir"
376
+ restore_staging=$(mktemp -d "${target_dir}.restore.XXXXXX") || {
377
+ print_error "Failed to create agents restore staging directory"
378
+ return 1
379
+ }
380
+ old_dir="${target_dir}.restore-old.$$"
381
+ rm -rf "$old_dir"
382
+
383
+ if ! cp -a "$latest_backup/." "$restore_staging/"; then
384
+ print_error "Failed to stage agents backup for restore: $latest_backup"
385
+ rm -rf "$restore_staging"
386
+ return 1
387
+ fi
388
+
389
+ if [[ -d "$target_dir" ]]; then
390
+ if ! mv "$target_dir" "$old_dir"; then
391
+ print_error "Failed to move current agents directory aside during restore — agents directory preserved"
392
+ rm -rf "$restore_staging"
393
+ return 1
394
+ fi
395
+ fi
396
+
397
+ if mv "$restore_staging" "$target_dir"; then
398
+ rm -rf "$old_dir"
374
399
  print_success "Restored agents directory from backup"
375
400
  return 0
376
401
  fi
377
402
 
378
- print_error "Failed to restore agents directory from backup: $latest_backup"
403
+ print_error "Failed to move staged agents backup into place — attempting restore rollback"
404
+ if [[ -d "$old_dir" ]]; then
405
+ if mv "$old_dir" "$target_dir"; then
406
+ print_info "Restore rollback successful — previous agents directory restored"
407
+ else
408
+ print_error "Restore rollback failed — previous agents directory preserved in $old_dir"
409
+ fi
410
+ fi
411
+ rm -rf "$restore_staging"
379
412
  return 1
380
413
  }
381
414
 
package/setup.sh CHANGED
@@ -12,7 +12,7 @@ shopt -s inherit_errexit 2>/dev/null || true
12
12
  # AI Assistant Server Access Framework Setup Script
13
13
  # Helps developers set up the framework for their infrastructure
14
14
  #
15
- # Version: 3.13.93
15
+ # Version: 3.13.94
16
16
  #
17
17
  # Quick Install:
18
18
  # npm install -g aidevops && aidevops update (recommended)
@@ -88,6 +88,10 @@ if [[ -d "$SETUP_MODULES_DIR" ]]; then
88
88
  # shellcheck disable=SC1091
89
89
  source "$SETUP_MODULES_DIR/_routines.sh"
90
90
  # shellcheck disable=SC1091
91
+ source "$SETUP_MODULES_DIR/_scheduler_runtime.sh"
92
+ # shellcheck disable=SC1091
93
+ source "$SETUP_MODULES_DIR/_runtime_helpers.sh"
94
+ # shellcheck disable=SC1091
91
95
  source "$SETUP_MODULES_DIR/_privacy_guard.sh"
92
96
  # shellcheck disable=SC1091
93
97
  source "$SETUP_MODULES_DIR/_complexity_guard.sh"
@@ -195,625 +199,11 @@ _resolve_main_worktree_dir() {
195
199
  return 0
196
200
  }
197
201
 
198
- # Ensure the crontab has a single PATH= line at the top with the current $PATH.
199
- # Individual cron entries must NOT set inline PATH= it overrides the global one
200
- # and hardcodes system-specific paths (nvm, bun, cargo, etc.). This function
201
- # manages a tagged comment + PATH line pair; re-running setup.sh updates it
202
- # idempotently. The marker must be a separate comment line because crontab does
203
- # NOT support inline comments on environment variable lines — anything after
204
- # PATH= is treated as part of the value.
205
- _ensure_cron_path() {
206
- local current_crontab marker="# aidevops-path"
207
- current_crontab=$(crontab -l 2>/dev/null) || current_crontab=""
208
-
209
- # Deduplicate PATH entries (preserving order)
210
- # Bash 3.2 compat: no associative arrays — use string-based seen list
211
- local deduped_path=""
212
- local seen_dirs=" "
213
- local IFS=':'
214
- for dir in $PATH; do
215
- if [[ -n "$dir" && "$seen_dirs" != *" ${dir} "* ]]; then
216
- seen_dirs="${seen_dirs}${dir} "
217
- deduped_path="${deduped_path:+${deduped_path}:}${dir}"
218
- fi
219
- done
220
- unset IFS
221
-
222
- # Marker on its own line, PATH on the next — crontab treats everything
223
- # after PATH= as the value (no inline comments)
224
- local path_block="${marker}
225
- PATH=${deduped_path}"
226
-
227
- # Remove only the aidevops-managed marker + PATH pair.
228
- # User-owned PATH= lines are left untouched.
229
- local filtered
230
- filtered=$(printf '%s\n' "$current_crontab" | awk -v marker="$marker" '
231
- $0 == marker { drop_next_path=1; next }
232
- drop_next_path && /^PATH=/ { drop_next_path=0; next }
233
- { drop_next_path=0; print }
234
- ')
235
-
236
- if [[ -n "$filtered" ]]; then
237
- current_crontab="${path_block}
238
- ${filtered}"
239
- else
240
- current_crontab="$path_block"
241
- fi
242
-
243
- printf '%s\n' "$current_crontab" | crontab - 2>/dev/null || true
244
- return 0
245
- }
246
-
247
- # Check if a launchd agent is loaded (SIGPIPE-safe for pipefail, t1265)
248
- _launchd_has_agent() {
249
- local label="$1"
250
- local output
251
- output=$(launchctl list 2>/dev/null) || true
252
- echo "$output" | grep -qF "$label"
253
- return $?
254
- }
255
-
256
- _launchd_agent_state() {
257
- local label="$1"
258
- local state=""
259
- state=$(launchctl print "gui/$(id -u)/${label}" 2>/dev/null | awk -F'= ' '/state =/ { print $2; exit }' || true)
260
- printf '%s\n' "$state"
261
- return 0
262
- }
263
-
264
- _launchd_bootout_bootstrap() {
265
- local label="$1"
266
- local plist_path="$2"
267
- local domain
268
- domain="gui/$(id -u)"
269
-
270
- launchctl bootout "${domain}/${label}" 2>/dev/null || true
271
- launchctl bootstrap "$domain" "$plist_path" 2>/dev/null
272
- return $?
273
- }
274
-
275
- _launchd_recover_xpcproxy_if_stuck() {
276
- local label="$1"
277
- local plist_path="$2"
278
- local state
279
- state=$(_launchd_agent_state "$label")
280
- if [[ "$state" != "xpcproxy" ]]; then
281
- return 0
282
- fi
283
-
284
- print_warning "LaunchAgent $label stuck in xpcproxy; reloading with bootout/bootstrap"
285
- if ! _launchd_bootout_bootstrap "$label" "$plist_path"; then
286
- return 1
287
- fi
288
-
289
- state=$(_launchd_agent_state "$label")
290
- if [[ "$state" == "xpcproxy" ]]; then
291
- print_warning "LaunchAgent $label still stuck in xpcproxy after recovery"
292
- return 1
293
- fi
294
- return 0
295
- }
296
-
297
- _launchd_load_agent() {
298
- local label="$1"
299
- local plist_path="$2"
300
-
301
- if launchctl load "$plist_path" 2>/dev/null; then
302
- _launchd_recover_xpcproxy_if_stuck "$label" "$plist_path" || return 1
303
- return 0
304
- fi
305
-
306
- if _launchd_bootout_bootstrap "$label" "$plist_path"; then
307
- _launchd_recover_xpcproxy_if_stuck "$label" "$plist_path" || return 1
308
- return 0
309
- fi
310
- return 1
311
- }
312
-
313
- _launchd_kickstart_and_recover() {
314
- local label="$1"
315
- local plist_path="$2"
316
- local domain
317
- domain="gui/$(id -u)"
318
-
319
- launchctl kickstart -k "${domain}/${label}" 2>/dev/null || return 1
320
- _launchd_recover_xpcproxy_if_stuck "$label" "$plist_path"
321
- return $?
322
- }
323
-
324
- # Install a launchd plist only if its content has changed.
325
- # Avoids unnecessary unload/reload which resets StartInterval timers.
326
- # Usage: _launchd_install_if_changed <label> <plist_path> <new_content>
327
- # Returns: 0 = installed or unchanged, 1 = failed to load
328
- _launchd_install_if_changed() {
329
- local label="$1"
330
- local plist_path="$2"
331
- local new_content="$3"
332
-
333
- # Compare with existing plist — skip reload if identical
334
- if [[ -f "$plist_path" ]]; then
335
- local existing_content
336
- existing_content=$(cat "$plist_path")
337
- if [[ "$existing_content" == "$new_content" ]]; then
338
- # Ensure it's loaded even if content unchanged
339
- if ! _launchd_has_agent "$label"; then
340
- _launchd_load_agent "$label" "$plist_path" || return 1
341
- else
342
- _launchd_recover_xpcproxy_if_stuck "$label" "$plist_path" || return 1
343
- fi
344
- return 0
345
- fi
346
- # Content changed — unload before replacing
347
- if _launchd_has_agent "$label"; then
348
- launchctl unload "$plist_path" 2>/dev/null || true
349
- fi
350
- fi
351
-
352
- # Atomic write: build at sibling tmp path, then rename into place.
353
- # If printf is killed mid-write, the destination is untouched.
354
- # mktemp avoids predictable tmp names (defense-in-depth against symlink attacks).
355
- local tmp_plist
356
- tmp_plist=$(mktemp "${plist_path}.XXXXXX") || return 1
357
- # Guard: refuse to write empty content — catching this before the write avoids
358
- # creating a tmp file that the file-size check would also catch, but the
359
- # content check is more direct and gives a clearer failure point.
360
- if [[ -z "$new_content" ]]; then
361
- rm -f "$tmp_plist"
362
- return 1
363
- fi
364
- if ! printf '%s\n' "$new_content" >"$tmp_plist"; then
365
- rm -f "$tmp_plist"
366
- return 1
367
- fi
368
- # Defensive: refuse to install an empty file (should be guaranteed by the
369
- # caller's content check, but guard here too).
370
- if [[ ! -s "$tmp_plist" ]]; then
371
- rm -f "$tmp_plist"
372
- return 1
373
- fi
374
- if ! mv -f "$tmp_plist" "$plist_path"; then
375
- rm -f "$tmp_plist"
376
- return 1
377
- fi
378
- _launchd_load_agent "$label" "$plist_path" || return 1
379
- return 0
380
- }
381
-
382
- # Detect whether a scheduler is already installed via launchd, cron, or systemd.
383
- # Optionally migrates legacy launchd labels / cron entries to launchd on macOS.
384
- # Args: arg1=scheduler_name, arg2=launchd_label, arg3=legacy_launchd_label,
385
- # arg4=cron_marker, arg5=migrate_script, arg6=migrate_arg, arg7=migrate_hint
386
- # arg8=systemd_unit (optional — base name without .timer suffix, e.g. "aidevops-supervisor-pulse")
387
- _scheduler_detect_installed() {
388
- local scheduler_name="$1"
389
- local launchd_label="$2"
390
- local legacy_launchd_label="$3"
391
- local cron_marker="$4"
392
- local migrate_script="$5"
393
- local migrate_arg="$6"
394
- local migrate_hint="$7"
395
- local systemd_unit="${8:-}"
396
- local installed=false
397
-
398
- if _launchd_has_agent "$launchd_label"; then
399
- installed=true
400
- elif [[ -n "$legacy_launchd_label" ]] && _launchd_has_agent "$legacy_launchd_label"; then
401
- if [[ -n "$migrate_script" ]] && [[ -x "$migrate_script" ]]; then
402
- if bash "$migrate_script" "$migrate_arg" >/dev/null 2>&1; then
403
- print_info "$scheduler_name LaunchAgent migrated to new label"
404
- else
405
- print_warning "$scheduler_name label migration failed. Run: $migrate_hint"
406
- fi
407
- fi
408
- installed=true
409
- elif crontab -l 2>/dev/null | grep -qF "$cron_marker"; then
410
- if [[ "$PLATFORM_MACOS" == "true" ]] && [[ -n "$migrate_script" ]] && [[ -x "$migrate_script" ]]; then
411
- if bash "$migrate_script" "$migrate_arg" >/dev/null 2>&1; then
412
- print_info "$scheduler_name migrated from cron to launchd"
413
- else
414
- print_warning "$scheduler_name cron->launchd migration failed. Run: $migrate_hint"
415
- fi
416
- fi
417
- installed=true
418
- elif [[ -n "$systemd_unit" ]] && command -v systemctl >/dev/null 2>&1 &&
419
- systemctl --user is-enabled "${systemd_unit}.timer" >/dev/null 2>&1; then
420
- # Systemd user timer detected (GH#17381 — Linux systemd path was missing)
421
- installed=true
422
- fi
423
-
424
- if [[ "$installed" == "true" ]]; then
425
- return 0
426
- fi
427
-
428
- return 1
429
- }
430
-
431
- _should_setup_noninteractive_supervisor_pulse() {
432
- local pulse_label="com.aidevops.aidevops-supervisor-pulse"
433
-
434
- if _scheduler_detect_installed \
435
- "Supervisor pulse" \
436
- "$pulse_label" \
437
- "" \
438
- "pulse-wrapper" \
439
- "" \
440
- "" \
441
- "" \
442
- "aidevops-supervisor-pulse"; then
443
- return 0
444
- fi
445
-
446
- if type config_enabled &>/dev/null && config_enabled "orchestration.supervisor_pulse"; then
447
- return 0
448
- fi
449
-
450
- return 1
451
- }
452
-
453
- # Generic non-interactive scheduler detection (GH#17695 Finding B).
454
- # Returns 0 if the named scheduler is already installed on any backend,
455
- # meaning it should be regenerated during non-interactive setup.
456
- # Args: arg1=name arg2=launchd_label arg3=cron_marker arg4=systemd_unit
457
- _should_setup_noninteractive_scheduler() {
458
- local name="$1"
459
- local launchd_label="$2"
460
- local cron_marker="$3"
461
- local systemd_unit="${4:-}"
462
-
463
- if _scheduler_detect_installed \
464
- "$name" \
465
- "$launchd_label" \
466
- "" \
467
- "$cron_marker" \
468
- "" \
469
- "" \
470
- "" \
471
- "$systemd_unit"; then
472
- return 0
473
- fi
474
-
475
- return 1
476
- }
477
-
478
- # Stats-wrapper is a REQUIRED dependency of the supervisor pulse — the pulse
479
- # delegates all health dashboard + quality sweep work to it (t1429). If the
480
- # supervisor pulse is installed or consented, stats-wrapper must also be
481
- # installed, even on first-time non-interactive runs. Without this escape
482
- # hatch, auto-update on a fresh machine installs the pulse but not the
483
- # stats-wrapper, leaving the health dashboard permanently stale (t2418,
484
- # GH#20016 — canonical 11-day staleness on #10944 on 2026-04-20).
485
- _should_setup_noninteractive_stats_wrapper() {
486
- if _should_setup_noninteractive_scheduler \
487
- "Stats wrapper" \
488
- "com.aidevops.aidevops-stats-wrapper" \
489
- "aidevops: stats-wrapper" \
490
- "aidevops-stats-wrapper"; then
491
- return 0
492
- fi
493
-
494
- # Pulse-dependency escape hatch: install stats-wrapper whenever the
495
- # supervisor pulse is (or will be) enabled. Pulse itself also honours
496
- # config consent in the non-interactive path, so following its gate
497
- # keeps the two schedulers in lockstep.
498
- if _should_setup_noninteractive_supervisor_pulse; then
499
- return 0
500
- fi
501
-
502
- return 1
503
- }
504
-
505
- # Pulse-merge-routine is a REQUIRED dependency of the supervisor pulse — it
506
- # is the merge-side of pulse, running merge_ready_prs_all_repos() on a fast
507
- # 120s cadence so green PRs land within ~3 min of CI completion instead of
508
- # waiting for the next full pulse cycle (t2862, GH#20919). Without this
509
- # escape hatch, auto-update on existing systems never installs the routine
510
- # (the generic _should_setup_noninteractive_scheduler chicken-and-egg gate
511
- # returns 0 only when the scheduler is ALREADY installed). The result on
512
- # the wild was the deterministic_merge_pass running 1-2x/24h instead of
513
- # every 2 min, leaving green PRs unmerged for 30+ hours (t3036, GH#21616).
514
- # Mirrors the stats-wrapper escape hatch above (t2418, GH#20016).
515
- _should_setup_noninteractive_pulse_merge_routine() {
516
- if _should_setup_noninteractive_scheduler \
517
- "Pulse merge routine" \
518
- "sh.aidevops.pulse-merge-routine" \
519
- "aidevops: pulse-merge-routine" \
520
- "aidevops-pulse-merge-routine"; then
521
- return 0
522
- fi
202
+ # Scheduler runtime helpers are sourced from .agents/scripts/setup/_scheduler_runtime.sh
203
+ # with the rest of the setup modules near the top of this file.
523
204
 
524
- # Pulse-dependency escape hatch: install the merge routine whenever the
525
- # supervisor pulse is (or will be) enabled. The routine is layered
526
- # defense for the in-cycle merge call in pulse-wrapper.sh, which is
527
- # kept as a safety net but short-circuits when this routine ran within
528
- # the last 60s.
529
- if _should_setup_noninteractive_supervisor_pulse; then
530
- return 0
531
- fi
532
-
533
- return 1
534
- }
535
-
536
- # Spinner for long-running operations
537
- # Usage: run_with_spinner "Installing package..." command arg1 arg2
538
- run_with_spinner() {
539
- local message="$1"
540
- shift
541
- local pid
542
- local spin_chars='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
543
- local i=0
544
-
545
- # Suppress Homebrew's slow auto-update for all backgrounded brew commands.
546
- # run_with_spinner backgrounds via "$@" &, so env var prefix syntax
547
- # (VAR=x cmd) doesn't propagate. Export globally for the child process.
548
- local _brew_was_set="${HOMEBREW_NO_AUTO_UPDATE:-}"
549
- local _cmd="${1:-}"
550
- local _subcmd="${2:-}"
551
- if [[ "$_cmd" == "brew" && "$_subcmd" != "update" ]]; then
552
- export HOMEBREW_NO_AUTO_UPDATE=1
553
- fi
554
-
555
- # Start command in background
556
- "$@" &>/dev/null &
557
- pid=$!
558
-
559
- # Show spinner while command runs
560
- printf "${BLUE}[INFO]${NC} %s " "$message"
561
- while kill -0 "$pid" 2>/dev/null; do
562
- printf "\r${BLUE}[INFO]${NC} %s %s" "$message" "${spin_chars:i++%${#spin_chars}:1}"
563
- sleep 0.1
564
- done
565
-
566
- # Check exit status
567
- wait "$pid"
568
- local exit_code=$?
569
-
570
- # Restore HOMEBREW_NO_AUTO_UPDATE to previous state
571
- if [[ -z "$_brew_was_set" ]]; then
572
- unset HOMEBREW_NO_AUTO_UPDATE
573
- fi
574
-
575
- # Clear spinner and show result
576
- printf "\r"
577
- if [[ $exit_code -eq 0 ]]; then
578
- print_success "$message done"
579
- else
580
- print_error "$message failed"
581
- fi
582
-
583
- return $exit_code
584
- }
585
-
586
- # Verified install: download script to temp file, inspect, then execute
587
- # Replaces unsafe curl|sh patterns with download-verify-execute
588
- # Usage: verified_install "description" "url" [extra_args...]
589
- # Options (set before calling):
590
- # VERIFIED_INSTALL_SUDO="true" - run with sudo
591
- # VERIFIED_INSTALL_SHELL="sh" - use sh instead of bash (default: bash)
592
- # Returns: 0 on success, 1 on failure
593
- verified_install() {
594
- local description="$1"
595
- local url="$2"
596
- shift 2
597
- local extra_args=("$@")
598
- local shell="${VERIFIED_INSTALL_SHELL:-bash}"
599
- local use_sudo="${VERIFIED_INSTALL_SUDO:-false}"
600
-
601
- # Reset options for next call
602
- VERIFIED_INSTALL_SUDO="false"
603
- VERIFIED_INSTALL_SHELL="bash"
604
-
605
- # Create secure temp file
606
- local tmp_script
607
- # t2997: drop .sh — XXXXXX must be at end for BSD mktemp.
608
- tmp_script=$(mktemp "${TMPDIR:-/tmp}/aidevops-install-XXXXXX") || {
609
- print_error "Failed to create temp file for $description"
610
- return 1
611
- }
612
-
613
- # Ensure cleanup on exit from this function
614
- # shellcheck disable=SC2064
615
- trap "rm -f '$tmp_script'" RETURN
616
-
617
- # Download script to file (not piped to shell)
618
- print_info "Downloading $description install script..."
619
- if ! curl -fsSL "$url" -o "$tmp_script" 2>/dev/null; then
620
- print_error "Failed to download $description install script from $url"
621
- return 1
622
- fi
623
-
624
- # Verify download is non-empty and looks like a script
625
- if [[ ! -s "$tmp_script" ]]; then
626
- print_error "Downloaded $description script is empty"
627
- return 1
628
- fi
629
-
630
- # Basic content safety check: reject binary content
631
- if file "$tmp_script" 2>/dev/null | grep -qv 'text'; then
632
- print_error "Downloaded $description script appears to be binary, not a shell script"
633
- return 1
634
- fi
635
-
636
- # Make executable
637
- chmod +x "$tmp_script"
638
-
639
- # Execute from file
640
- # Build cmd array once; prepend sudo conditionally to avoid duplicating the safe expansion
641
- # Use ${extra_args[@]+"${extra_args[@]}"} for safe expansion under set -u when array is empty
642
- local cmd=()
643
- [[ "$use_sudo" == "true" ]] && cmd+=(sudo)
644
- cmd+=("$shell" "$tmp_script" ${extra_args[@]+"${extra_args[@]}"})
645
-
646
- if "${cmd[@]}"; then
647
- print_success "$description installed"
648
- return 0
649
- else
650
- print_error "$description installation failed"
651
- return 1
652
- fi
653
- }
654
-
655
- # Find OpenCode config file (checks multiple possible locations)
656
- # Returns: path to config file, or empty string if not found
657
- find_opencode_config() {
658
- local candidates=(
659
- "$HOME/.config/opencode/opencode.json" # XDG standard (Linux, some macOS)
660
- "$HOME/.opencode/opencode.json" # Alternative location
661
- "$HOME/Library/Application Support/opencode/opencode.json" # macOS standard
662
- )
663
- for candidate in "${candidates[@]}"; do
664
- if [[ -f "$candidate" ]]; then
665
- echo "$candidate"
666
- return 0
667
- fi
668
- done
669
- return 1
670
- }
671
-
672
- # get_latest_homebrew_python_formula() and find_python3() are defined in
673
- # _common.sh (sourced above). Not duplicated here — see GH#5239 review.
674
-
675
- # Install a package globally via npm, with sudo when needed on Linux.
676
- # Usage: npm_global_install "package-name" OR npm_global_install "package@version"
677
- # On Linux with apt-installed npm, automatically prepends sudo.
678
- # Returns: 0 on success, 1 on failure
679
- npm_global_install() {
680
- local pkg="$1"
681
-
682
- if command -v npm >/dev/null 2>&1; then
683
- # npm global installs need sudo on Linux when prefix dir isn't writable
684
- if [[ "$(uname)" != "Darwin" ]] && [[ ! -w "$(npm config get prefix 2>/dev/null)/lib" ]]; then
685
- sudo npm install -g "$pkg"
686
- else
687
- npm install -g "$pkg"
688
- fi
689
- return $?
690
- else
691
- return 1
692
- fi
693
- }
694
-
695
- # Prompt the user for input, with non-interactive fallback.
696
- # Canonical definition in .agents/scripts/setup/_common.sh; this fallback
697
- # ensures the function exists even when _common.sh was not sourced (e.g.
698
- # bootstrap from curl where setup-modules/ doesn't exist yet).
699
- if ! type setup_prompt &>/dev/null; then
700
- setup_prompt() {
701
- local var_name="$1"
702
- local prompt_text="$2"
703
- local default_value="${3:-}"
704
-
705
- # Non-interactive: use default without prompting
706
- if [[ "${NON_INTERACTIVE:-false}" == "true" ]] || [[ ! -t 0 ]]; then
707
- # shellcheck disable=SC2059 # var_name is a variable name, not a format string
708
- printf -v "$var_name" '%s' "$default_value"
709
- return 0
710
- fi
711
-
712
- local _setup_prompt_reply=""
713
- read -r -p "$prompt_text" _setup_prompt_reply || _setup_prompt_reply="$default_value"
714
- # shellcheck disable=SC2059 # var_name is a variable name, not a format string
715
- printf -v "$var_name" '%s' "$_setup_prompt_reply"
716
- return 0
717
- }
718
- fi
719
-
720
- # Confirm step in interactive mode
721
- # Usage: confirm_step "Step description" && function_to_run
722
- # Returns: 0 if confirmed or not interactive, 1 if skipped
723
- confirm_step() {
724
- local step_name="$1"
725
-
726
- # Skip confirmation in non-interactive mode
727
- if [[ "$INTERACTIVE_MODE" != "true" ]]; then
728
- return 0
729
- fi
730
-
731
- echo ""
732
- echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
733
- echo -e "${BLUE}Step:${NC} $step_name"
734
- echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
735
-
736
- while true; do
737
- echo -n -e "${GREEN}Run this step? [Y]es / [n]o / [q]uit: ${NC}"
738
- read -r response
739
- # Convert to lowercase (bash 3.2 compatible)
740
- response=$(echo "$response" | tr '[:upper:]' '[:lower:]')
741
- case "$response" in
742
- y | yes | "")
743
- return 0
744
- ;;
745
- n | no | s | skip)
746
- print_warning "Skipped: $step_name"
747
- return 1
748
- ;;
749
- q | quit | exit)
750
- echo ""
751
- print_info "Setup cancelled by user"
752
- exit 0
753
- ;;
754
- *)
755
- echo "Please answer: y (yes), n (no), or q (quit)"
756
- ;;
757
- esac
758
- done
759
- }
760
-
761
- # Backup rotation settings
762
- BACKUP_KEEP_COUNT=10
763
-
764
- # Create a backup with rotation (keeps last N backups)
765
- # Usage: create_backup_with_rotation <source_path> <backup_name>
766
- # Example: create_backup_with_rotation "$target_dir" "agents"
767
- # Creates: ~/.aidevops/agents-backups/20251221_123456/
768
- create_backup_with_rotation() {
769
- local source_path="$1"
770
- local backup_name="$2"
771
- local backup_base="$HOME/.aidevops/${backup_name}-backups"
772
- local backup_dir
773
- backup_dir="$backup_base/$(date +%Y%m%d_%H%M%S)"
774
-
775
- # Create backup directory
776
- mkdir -p "$backup_dir"
777
-
778
- # Copy source to backup (tolerant of broken symlinks / missing entries)
779
- if [[ -d "$source_path" ]]; then
780
- if command -v rsync >/dev/null 2>&1 && rsync --help 2>&1 | grep -q -- '--ignore-missing-args'; then
781
- # rsync >= 3.1.0: --ignore-missing-args skips missing/broken entries gracefully
782
- if ! rsync -a --ignore-missing-args "$source_path/" "$backup_dir/$(basename "$source_path")/" 2>/dev/null; then
783
- print_warning "Backup had partial failures (broken symlinks?), continuing"
784
- fi
785
- else
786
- # Fallback: cp -R may fail on broken symlinks under set -e,
787
- # so run in a subshell that tolerates errors
788
- if ! (cp -R "$source_path" "$backup_dir/" 2>/dev/null); then
789
- print_warning "Backup had partial failures (broken symlinks?), continuing"
790
- fi
791
- fi
792
- elif [[ -f "$source_path" ]]; then
793
- cp "$source_path" "$backup_dir/"
794
- else
795
- print_warning "Source path does not exist: $source_path"
796
- return 1
797
- fi
798
-
799
- print_info "Backed up to $backup_dir"
800
-
801
- # Rotate old backups (keep last N)
802
- local backup_count
803
- backup_count=$(find "$backup_base" -maxdepth 1 -type d -name "20*" 2>/dev/null | wc -l | tr -d ' ')
804
-
805
- if [[ $backup_count -gt $BACKUP_KEEP_COUNT ]]; then
806
- local to_delete=$((backup_count - BACKUP_KEEP_COUNT))
807
- print_info "Rotating backups: removing $to_delete old backup(s), keeping last $BACKUP_KEEP_COUNT"
808
-
809
- # Delete oldest backups (sorted by name = sorted by date)
810
- find "$backup_base" -maxdepth 1 -type d -name "20*" 2>/dev/null | sort | head -n "$to_delete" | while read -r old_backup; do
811
- rm -rf "$old_backup"
812
- done
813
- fi
814
-
815
- return 0
816
- }
205
+ # Runtime helper functions are sourced from .agents/scripts/setup/_runtime_helpers.sh
206
+ # with the rest of the setup modules near the top of this file.
817
207
 
818
208
  # Validate namespace string for safe use in paths and shell commands
819
209
  # Returns 0 if valid, 1 if invalid