forked from AIPentest/CyberStrikeAI
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathupgrade.sh
More file actions
735 lines (648 loc) · 24.3 KB
/
Copy pathupgrade.sh
File metadata and controls
735 lines (648 loc) · 24.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
#!/usr/bin/env bash
set -euo pipefail
# CyberStrikeAI upgrade entry point.
#
# Two installation kinds, decided at runtime from this directory itself:
#
# 1. git work tree (the normal case, and the only honest one for a fork):
# the update is delegated to the platform binary, `./cyberstrike-ai -update`, which
# fetches the remote *this directory already tracks*, fast-forwards, rebuilds with
# `go build` and swaps the binary in atomically. No repository name is hardcoded on
# this path, so "upgrade" can never overwrite your work with somebody else's code.
# Refusals (local source edits, a diverged branch) come from that same implementation
# and name the files/commits involved.
#
# 2. Plain directory (Release tarball install, no git): falls back to downloading a
# Release tarball and synchronizing it with `rsync --delete`. Here the source
# repository must be named, so it is taken from --repo, else the GITHUB_REPO
# environment variable, and only if neither is set from the built-in default - with a
# warning that says out loud which repository the code is coming from.
#
# Default preserves:
# - config.yaml
# - data/
# - venv/ (disabled with --no-venv)
# - tools/ (user extensions; never overwritten by upgrade)
# - roles/, skills/, agents/, bundles/
# - the rollback points of an earlier one-click update:
# .update-backup/, .update-state.json, cyberstrike-ai.prev
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
BINARY_NAME="cyberstrike-ai"
CONFIG_FILE="$ROOT_DIR/config.yaml"
DATA_DIR="$ROOT_DIR/data"
VENV_DIR="$ROOT_DIR/venv"
KNOWLEDGE_BASE_DIR="$ROOT_DIR/knowledge_base"
BUNDLES_DIR="$ROOT_DIR/bundles"
BACKUP_BASE_DIR="$ROOT_DIR/.upgrade-backup"
UPGRADE_TMP_DIR=""
# Built-in fallback, used ONLY when this directory is not a git work tree and neither
# --repo nor GITHUB_REPO was given. See resolve_repo().
DEFAULT_GITHUB_REPO="Ed1s0nZ/CyberStrikeAI"
GITHUB_REPO="${GITHUB_REPO:-}"
REPO_ARG=""
TAG=""
PRESERVE_VENV=1
STOP_SERVICE=1
FORCE_STOP=0
YES=0
CHECK_ONLY=0
# Set by resolve_install_kind(): "git" or "tarball".
INSTALL_KIND=""
usage() {
cat <<'EOF'
Usage:
./upgrade.sh [--check] [--yes] [--no-venv] [--no-stop] [--force-stop]
[--tag vX.Y.Z] [--repo <owner/name>]
What it does depends on the installation kind (detected automatically):
git work tree Updates from the remote this directory already tracks, by calling
./cyberstrike-ai -update (fetch, fast-forward, go build, swap binary).
--tag/--repo are ignored on this path. Your own roles/skills/tools/
agents/bundles/data/config are put aside and restored by the platform;
local source edits or a diverged branch stop the update instead of
being overwritten.
plain directory (Release tarball install, no git) Downloads the Release tarball and
syncs it in with rsync --delete. --repo / GITHUB_REPO apply here.
Options:
--check Report only, change nothing: never stops the service, never writes.
git tree: ./cyberstrike-ai -check-update, or
go run ./cmd/server -check-update while there is no binary yet.
plain directory: prints the newest Release tag and the version
recorded in config.yaml.
--repo <owner/name> Release repository to read (tarball installs only). Takes precedence
over $GITHUB_REPO. Without either, the built-in default is used and
the script warns about it.
--tag <tag> Specify GitHub Release tag (e.g. v1.3.28).
If omitted, the script uses the latest release.
--no-venv Do not preserve venv/ (Python deps will be re-installed).
--no-stop Do not stop the running service, and do not start it afterwards
(use this under systemd or any supervisor; restart the unit yourself).
--force-stop If no process matching current directory is found, also stop
any cyberstrike-ai processes (use with caution).
--yes Do not ask for confirmation.
Rollback:
./cyberstrike-ai -update-rollback
Undoes the last one-click update (back to the commit and the binary kept before it).
EOF
}
log() { printf "%s\n" "$*"; }
info() { log "[INFO] $*"; }
warn() { log "[WARN] $*"; }
err() { log "[ERROR] $*"; }
have_cmd() { command -v "$1" >/dev/null 2>&1; }
http_get() {
# $1: url
if have_cmd curl; then
# If GITHUB_TOKEN is provided, use it for api.github.com to avoid low rate limits.
if [[ -n "${GITHUB_TOKEN:-}" && "$1" == https://api.github.com/* ]]; then
# Do not use `-f` so we can parse GitHub error JSON bodies and show `message`.
curl -sSL -H "Authorization: Bearer ${GITHUB_TOKEN}" "$1"
else
# Do not use `-f` so we can parse GitHub error JSON bodies and show `message`.
curl -sSL "$1"
fi
elif have_cmd wget; then
wget -qO- "$1"
else
err "curl or wget is required to download GitHub releases. Please install one of them."
exit 1
fi
}
physical_path() {
# $1: existing directory; symlinks resolved, so paths reported by git compare cleanly
# (macOS /tmp and /var are symlinks).
( cd "$1" 2>/dev/null && pwd -P ) || true
}
is_git_work_tree() {
# $1: directory
have_cmd git && git -C "$1" rev-parse --is-inside-work-tree >/dev/null 2>&1
}
resolve_install_kind() {
if ! have_cmd git; then
INSTALL_KIND="tarball"
info "git is not installed: treating this directory as a tarball install (Release download)."
return 0
fi
if ! is_git_work_tree "$ROOT_DIR"; then
INSTALL_KIND="tarball"
info "This directory is not a git work tree: using the Release tarball path."
return 0
fi
local raw_top top_phys root_phys
raw_top="$(git -C "$ROOT_DIR" rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -n "$raw_top" ]]; then
top_phys="$(physical_path "$raw_top")"
root_phys="$(physical_path "$ROOT_DIR")"
if [[ -n "$top_phys" && "$top_phys" != "$root_phys" ]]; then
err "This script lives in ${ROOT_DIR}, which is a subdirectory of the repository root ${raw_top}."
err "One-click update only ever moves a repository root, so run the upgrade from ${raw_top}."
exit 1
fi
fi
INSTALL_KIND="git"
}
show_git_source() {
# Say out loud where the code is coming from: that is the whole point of this path.
local branch
branch="$(git -C "$ROOT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
info "Install directory: ${ROOT_DIR}"
info "Branch: ${branch:-unknown}"
local remotes name url
remotes="$(git -C "$ROOT_DIR" remote 2>/dev/null || true)"
if [[ -z "$remotes" ]]; then
warn "This repository has no git remote, so there is nothing to fetch. Add one (git remote add ...) before updating."
return 0
fi
info "Remotes this directory tracks (the update pulls from its own remote, never from a hardcoded third-party one):"
for name in $remotes; do
url="$(git -C "$ROOT_DIR" config --get "remote.${name}.url" 2>/dev/null || true)"
info " - ${name}: ${url:-unknown}"
done
}
explain_no_go() {
err "No compiled ${BINARY_NAME} binary in ${ROOT_DIR} and no Go toolchain on PATH."
err "One of the two is required: an existing binary rebuilds itself, a source-only"
err "checkout needs Go to produce the first binary."
err "Install Go (this module requires Go 1.25 or newer):"
err " download: https://go.dev/dl/"
err " macOS: brew install go"
err " Debian/Ubuntu: sudo apt install golang-go (or the official tarball in /usr/local/go)"
err "Then re-run: ./upgrade.sh"
}
stop_service() {
# Try to stop the service that is running from the current project directory.
# If nothing is found and --force-stop is enabled, stop all cyberstrike-ai processes.
if [[ "$STOP_SERVICE" -ne 1 ]]; then
return 0
fi
local pids=""
if have_cmd pgrep; then
# Prefer matches where the command line contains the current project path.
pids="$(pgrep -f "${ROOT_DIR}.*${BINARY_NAME}" || true)"
if [[ -z "$pids" && "$FORCE_STOP" -eq 1 ]]; then
warn "No ${BINARY_NAME} process found under the current directory. Will try to force-stop all matching ${BINARY_NAME} processes."
pids="$(pgrep -f "${BINARY_NAME}" || true)"
fi
fi
if [[ -z "$pids" ]]; then
info "No ${BINARY_NAME} process detected (or no matching process). Skipping stop step."
return 0
fi
warn "Detected running PID(s): ${pids}"
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then
info "Sending SIGTERM to PID=${pid}..."
kill -TERM "$pid" 2>/dev/null || true
fi
done
# Wait for exit
local deadline=$((SECONDS + 20))
while [[ $SECONDS -lt $deadline ]]; do
local alive=0
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then
alive=1
break
fi
done
if [[ "$alive" -eq 0 ]]; then
info "Service stopped."
return 0
fi
sleep 1
done
warn "Timed out waiting for processes to exit. Still running PID(s): ${pids} (may still hold file handles)."
return 0
}
start_service() {
if [[ "$STOP_SERVICE" -ne 1 ]]; then
info "--no-stop: the service was left untouched. Restart it yourself (for example: systemctl restart cyberstrikeai)."
return 0
fi
if [[ ! -f "$ROOT_DIR/run.sh" ]]; then
warn "run.sh not found: starting the service is left to you."
return 0
fi
info "Starting service..."
chmod +x ./run.sh
./run.sh
}
confirm_or_exit() {
if [[ "$YES" -eq 1 ]]; then
return 0
fi
if [[ ! -t 0 ]]; then
err "Non-interactive terminal detected. Please add --yes to continue."
exit 1
fi
if [[ "$INSTALL_KIND" == "git" ]]; then
warn "About to update this installation from the remote it already tracks:"
info " - git fetch + fast-forward of ${ROOT_DIR}"
info " (refused with file names if source files are modified locally, or if the"
info " branch has diverged - that is a merge decision, not a download)"
info " - go build, then the binary is swapped; the previous one is kept as ${BINARY_NAME}.prev"
info " - your content (roles/skills/tools/agents/bundles/knowledge_base/data/config.yaml)"
info " is put aside before the merge and restored after it; the result lists what was kept"
info " - rollback stays available: .update-backup/<timestamp>/ + .update-state.json"
info " - Stop service: ${STOP_SERVICE}"
else
warn "About to perform upgrade:"
info " - Source repository: ${GITHUB_REPO:-unset} (this directory is not a git work tree)"
info " - Preserve config.yaml: yes"
info " - Preserve data/: yes"
if [[ "$PRESERVE_VENV" -eq 1 ]]; then
info " - Preserve venv/: yes"
else
info " - Preserve venv/: no (will remove old venv and re-install deps)"
fi
info " - Preserve tools/: yes (always)"
info " - Preserve roles/skills/agents/bundles: yes (always)"
info " - Preserve rollback points (.update-backup/, .update-state.json, ${BINARY_NAME}.prev): yes"
info " - Stop service: ${STOP_SERVICE}"
fi
echo ""
read -r -p "Continue? (y/N) " ans
if [[ "${ans:-N}" != "y" && "${ans:-N}" != "Y" ]]; then
err "Cancelled."
exit 1
fi
}
run_platform_update_cli() {
# $1: '-check-update' or '-update'. Same implementation the console page drives, so a
# keyboard session and the API cannot disagree about what an update means.
local flag="$1"
if [[ ! -f "$ROOT_DIR/$BINARY_NAME" ]]; then
err "Expected ${BINARY_NAME} in ${ROOT_DIR} but it is not there."
err "Run ./upgrade.sh again (it builds the binary first) or use --check."
return 1
fi
if [[ ! -x "$ROOT_DIR/$BINARY_NAME" ]]; then
chmod +x "./$BINARY_NAME"
fi
info "Running: ./${BINARY_NAME} ${flag}"
local rc=0
./"$BINARY_NAME" "$flag" || rc=$?
return "$rc"
}
report_update_refusal() {
# $1: exit code. The platform already printed the exact reason and the file names above;
# this only says what it means for the tree, because the two outcomes look the same.
warn "${BINARY_NAME} exited with code $1. Read the reason printed above."
info " - Local source edits / diverged branch: nothing was moved. Commit or restore your"
info " own source changes (or merge by hand), then run ./upgrade.sh again."
info " - No Go toolchain: the source DID move and your content was kept; the binary is"
info " still the previous one. Install Go and run ./upgrade.sh again to finish."
info " - Undo the last successful update: ./${BINARY_NAME} -update-rollback"
}
build_first_binary() {
info "No ${BINARY_NAME} binary yet: building one before updating (./cmd/server, first run may download modules)."
if ! have_cmd go; then
explain_no_go
exit 1
fi
if ! go build -o "$BINARY_NAME" ./cmd/server; then
err "The first build failed, so the platform has no binary to update itself with."
err "Fix the build (see the compiler output above) and run ./upgrade.sh again."
exit 1
fi
if [[ ! -f "$BINARY_NAME" ]]; then
err "go build reported success but produced no ${BINARY_NAME} in ${ROOT_DIR}."
exit 1
fi
chmod +x "./$BINARY_NAME"
}
check_git_install() {
# Read-only path: never stops or starts the service.
if [[ -f "$ROOT_DIR/$BINARY_NAME" ]]; then
local rc=0
run_platform_update_cli "-check-update" || rc=$?
if [[ "$rc" -ne 0 ]]; then
report_update_refusal "$rc"
fi
return "$rc"
fi
if have_cmd go; then
info "No ${BINARY_NAME} binary yet, so the check is asked of the source tree: go run compiles into the build cache and leaves this directory untouched."
local rc=0
go run ./cmd/server -check-update || rc=$?
if [[ "$rc" -ne 0 ]]; then
report_update_refusal "$rc"
fi
return "$rc"
fi
explain_no_go
exit 1
}
update_git_install() {
confirm_or_exit
stop_service
if [[ ! -f "$ROOT_DIR/$BINARY_NAME" ]]; then
build_first_binary
fi
if ! have_cmd go; then
warn "No Go toolchain on PATH: the update will move the source and keep your content, but it cannot rebuild the binary."
warn "Install Go and run ./upgrade.sh again to swap in the new binary."
fi
# A fast-forward may rewrite this script as well; harmless, the running copy is already
# open, and the next invocation reads the updated file.
local rc=0
run_platform_update_cli "-update" || rc=$?
if [[ "$rc" -ne 0 ]]; then
report_update_refusal "$rc"
return "$rc"
fi
info "Source and binary are in place (the running process is still the old one until it is restarted)."
start_service
}
ensure_git_style_env() {
# No hard requirement; just a sanity check.
if [[ ! -f "$CONFIG_FILE" ]]; then
err "Could not find ${CONFIG_FILE}. Please verify you are in the correct project directory."
exit 1
fi
}
resolve_repo() {
# Tarball path only: without a repository of our own, the source has to be named, and
# naming it silently is how a fork gets overwritten. --repo beats GITHUB_REPO, and the
# built-in default is a last resort that says so.
if [[ -n "$REPO_ARG" ]]; then
GITHUB_REPO="$REPO_ARG"
fi
if [[ -z "${GITHUB_REPO:-}" ]]; then
GITHUB_REPO="$DEFAULT_GITHUB_REPO"
warn "Neither --repo nor GITHUB_REPO was given, so the built-in default repository is used."
fi
if [[ ! "$GITHUB_REPO" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then
err "Invalid repository: ${GITHUB_REPO} (expected the form owner/name)."
exit 1
fi
warn "Not a git work tree: code will be downloaded from https://github.com/${GITHUB_REPO} (a Release tarball), not from this directory's own remote."
warn "If this installation is meant to track your fork, make it a git clone; ./upgrade.sh then pulls from the remote it already has."
}
backup_dir_tgz() {
# $1: label, $2: path
local label="$1"
local path="$2"
if [[ -e "$path" ]]; then
info "Backing up ${label} -> ${BACKUP_BASE_DIR}/$(basename "$path").tgz"
tar -czf "${BACKUP_BASE_DIR}/$(basename "$path").tgz" -C "$ROOT_DIR" "$(basename "$path")"
fi
}
backup_config() {
if [[ -f "$CONFIG_FILE" ]]; then
cp -a "$CONFIG_FILE" "${BACKUP_BASE_DIR}/config.yaml"
fi
}
resolve_tag() {
if [[ -n "$TAG" ]]; then
info "Using specified tag: $TAG"
return 0
fi
local api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/latest"
info "Fetching latest Release..."
local json
json="$(http_get "$api_url")"
TAG="$(printf '%s' "$json" | python3 - <<'PY'
import json, sys
data=json.loads(sys.stdin.read() or "{}")
print(data.get("tag_name",""))
PY
)"
if [[ -z "$TAG" ]]; then
local msg
msg="$(printf '%s' "$json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read() or '{}'); print(d.get('message',''))" 2>/dev/null || true)"
# Fallback: try query releases list (sometimes latest endpoint returns error JSON without tag_name).
local fallback_url="https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=1"
info "Fallback to: ${fallback_url}"
local fallback_json
fallback_json="$(http_get "$fallback_url" 2>/dev/null || true)"
local fallback_tag
fallback_tag="$(printf '%s' "$fallback_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read() or '[]'); print(d[0].get('tag_name','') if isinstance(d,list) and d else '')" 2>/dev/null || true)"
if [[ -n "$fallback_tag" ]]; then
TAG="$fallback_tag"
info "Latest Release tag (fallback): $TAG"
return 0
fi
local snippet
snippet="$(printf '%s' "$json" | python3 -c "import sys; s=sys.stdin.read(); print(s[:300].replace('\\n',' '))" 2>/dev/null || true)"
if [[ -n "$msg" ]]; then
err "Failed to fetch latest tag: ${msg}"
else
err "Failed to fetch latest tag."
fi
if [[ -n "$snippet" ]]; then
err "API response snippet: ${snippet}"
fi
err "Please try using --tag to specify the version, or set export GITHUB_TOKEN=\"...\"."
exit 1
fi
info "Latest Release tag: $TAG"
}
check_tarball_install() {
# Nothing to fast-forward without a repository of our own: report the newest published
# tag against the version recorded in config.yaml, and change no file.
resolve_tag
local current=""
if [[ -f "$CONFIG_FILE" ]]; then
current="$(grep -m1 -E '^[[:space:]]*version[[:space:]]*:' "$CONFIG_FILE" 2>/dev/null | cut -d: -f2- | tr -d ' "' || true)"
fi
info "Newest Release tag of ${GITHUB_REPO}: ${TAG}"
info "Version recorded in config.yaml: ${current:-unknown}"
info "Report only: nothing was stopped, downloaded or written. Run ./upgrade.sh without --check to apply."
}
update_config_version() {
# Replace config.yaml's version: ... with the specified tag.
local new_tag="$1"
python3 - "$CONFIG_FILE" "$new_tag" <<PY
import re, sys
path=sys.argv[1]
tag=sys.argv[2]
with open(path, "r", encoding="utf-8") as f:
lines=f.readlines()
out=[]
replaced=False
for line in lines:
if re.match(r'^\s*version\s*:', line):
out.append(f'version: "{tag}"\n')
replaced=True
else:
out.append(line)
if not replaced:
# If no version field is found, insert at the beginning (near the top).
out.insert(0, f'version: "{tag}"\n')
with open(path, "w", encoding="utf-8") as f:
f.writelines(out)
PY
}
sync_code() {
local tmp_dir="$1"
local new_src_dir="$2"
# rsync sync: overwrite files from the new version and delete removed files.
# Preserve user data/config (and optional directories).
if ! have_cmd rsync; then
err "rsync not found. This script depends on rsync for safe synchronization. Please install it and retry."
exit 1
fi
local -a rsync_excludes
rsync_excludes+=( "--exclude=.upgrade-backup/" )
rsync_excludes+=( "--exclude=config.yaml" )
rsync_excludes+=( "--exclude=data/" )
rsync_excludes+=( "--exclude=tmp/" )
if [[ "$PRESERVE_VENV" -eq 1 ]]; then
rsync_excludes+=( "--exclude=venv/" )
fi
# knowledge_base may not be referenced in config, but many users treat it as the knowledge files directory.
if [[ -d "$KNOWLEDGE_BASE_DIR" ]]; then
rsync_excludes+=( "--exclude=knowledge_base/" )
fi
# User tool extensions: never replace or delete during upgrade.
rsync_excludes+=( "--exclude=tools/" )
rsync_excludes+=( "--exclude=roles/" )
rsync_excludes+=( "--exclude=skills/" )
# Markdown agents and capability bundles are operator content too, and the platform's
# own update protects the same set.
rsync_excludes+=( "--exclude=agents/" )
rsync_excludes+=( "--exclude=bundles/" )
# Rollback points written by a one-click update: an rsync --delete that ate them would
# leave the installation with no way back.
rsync_excludes+=( "--exclude=.update-backup/" )
rsync_excludes+=( "--exclude=.update-staging/" )
rsync_excludes+=( "--exclude=.update-state.json" )
rsync_excludes+=( "--exclude=${BINARY_NAME}.prev" )
# Ensure this upgrade script itself is not deleted.
rsync_excludes+=( "--exclude=upgrade.sh" )
# shellcheck disable=SC2068
info "Syncing code into current directory (preserving data/config; using rsync --delete)..."
rsync -a --delete \
${rsync_excludes[@]} \
"${new_src_dir}/" "${ROOT_DIR}/"
}
update_tarball_install() {
ensure_git_style_env
confirm_or_exit
stop_service
resolve_tag
local ts
ts="$(date +"%Y%m%d_%H%M%S")"
BACKUP_BASE_DIR="${BACKUP_BASE_DIR}/${ts}"
mkdir -p "$BACKUP_BASE_DIR"
info "Starting backup into: $BACKUP_BASE_DIR"
backup_config
backup_dir_tgz "data" "$DATA_DIR"
if [[ "$PRESERVE_VENV" -eq 1 ]]; then
backup_dir_tgz "venv" "$VENV_DIR"
else
if [[ -d "$VENV_DIR" ]]; then
warn "With --no-venv: removing old venv/ (run.sh will re-install Python deps after upgrade)."
rm -rf "$VENV_DIR"
fi
fi
if [[ -d "$KNOWLEDGE_BASE_DIR" ]]; then
backup_dir_tgz "knowledge_base" "$KNOWLEDGE_BASE_DIR"
fi
if [[ -d "$ROOT_DIR/tools" ]]; then
backup_dir_tgz "tools" "$ROOT_DIR/tools"
fi
if [[ -d "$ROOT_DIR/roles" ]]; then
backup_dir_tgz "roles" "$ROOT_DIR/roles"
fi
if [[ -d "$ROOT_DIR/skills" ]]; then
backup_dir_tgz "skills" "$ROOT_DIR/skills"
fi
if [[ -d "$ROOT_DIR/agents" ]]; then
backup_dir_tgz "agents" "$ROOT_DIR/agents"
fi
if [[ -d "$BUNDLES_DIR" ]]; then
backup_dir_tgz "bundles" "$BUNDLES_DIR"
fi
UPGRADE_TMP_DIR="$(mktemp -d)"
trap 'rm -rf "${UPGRADE_TMP_DIR:-}" >/dev/null 2>&1 || true' EXIT
local tarball="${UPGRADE_TMP_DIR}/source.tar.gz"
local url="https://github.com/${GITHUB_REPO}/archive/refs/tags/${TAG}.tar.gz"
info "Downloading source package: ${url}"
http_get "$url" >"$tarball"
info "Extracting source package..."
tar -xzf "$tarball" -C "$UPGRADE_TMP_DIR"
# GitHub tarball usually creates a top-level directory.
local extracted_dir
extracted_dir="$(ls -d "${UPGRADE_TMP_DIR}"/*/ 2>/dev/null | head -n 1 || true)"
if [[ -z "$extracted_dir" || ! -f "${extracted_dir}/run.sh" ]]; then
err "run.sh not found in the extracted directory. Please check network/download contents."
exit 1
fi
sync_code "$UPGRADE_TMP_DIR" "$extracted_dir"
# Update config.yaml version display
if [[ -f "$CONFIG_FILE" ]]; then
info "Updating config.yaml version field to: $TAG"
update_config_version "$TAG"
fi
info "Upgrade complete."
start_service
}
main() {
while [[ $# -gt 0 ]]; do
case "$1" in
--check)
CHECK_ONLY=1
shift 1
;;
--repo)
REPO_ARG="${2:-}"
shift 2
;;
--tag)
TAG="${2:-}"
shift 2
;;
--no-venv)
PRESERVE_VENV=0
shift 1
;;
--no-stop)
STOP_SERVICE=0
shift 1
;;
--force-stop)
FORCE_STOP=1
shift 1
;;
--yes)
YES=1
shift 1
;;
-h|--help)
usage
exit 0
;;
*)
err "Unknown parameter: $1"
usage
exit 1
;;
esac
done
resolve_install_kind
if [[ "$INSTALL_KIND" == "git" ]]; then
if [[ -n "$TAG" ]]; then
warn "--tag names a GitHub Release and is ignored here: this directory fast-forwards to its own remote, not to a tag."
fi
if [[ -n "$REPO_ARG" ]]; then
warn "--repo names a GitHub Release repository and is ignored here: the remote is the one this directory already tracks."
fi
show_git_source
if [[ "$CHECK_ONLY" -eq 1 ]]; then
check_git_install
return 0
fi
update_git_install
return 0
fi
# Tarball install: the source has to be named, and naming it out loud comes first.
resolve_repo
if [[ "$CHECK_ONLY" -eq 1 ]]; then
check_tarball_install
return 0
fi
update_tarball_install
}
main "$@"