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 +1 -1
- package/aidevops-init-lib.sh +106 -9
- package/aidevops-repos-lib.sh +55 -2
- package/aidevops-update-lib.sh +29 -0
- package/aidevops.sh +8 -1
- package/package.json +1 -1
- package/setup-modules/agent-deploy.sh +37 -4
- package/setup.sh +9 -619
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.13.
|
|
1
|
+
3.13.94
|
package/aidevops-init-lib.sh
CHANGED
|
@@ -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
|
-
|
|
751
|
-
|
|
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
|
-
|
|
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
|
|
package/aidevops-repos-lib.sh
CHANGED
|
@@ -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
|
-
|
package/aidevops-update-lib.sh
CHANGED
|
@@ -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.
|
|
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
|
@@ -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
|
-
|
|
372
|
-
mkdir -p "$
|
|
373
|
-
|
|
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
|
|
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.
|
|
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
|
-
#
|
|
199
|
-
#
|
|
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
|
-
|
|
525
|
-
|
|
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
|