Repository navigation
Expand file tree
/
Copy pathsetup-environment.sh
More file actions
executable file
·1770 lines (1623 loc) · 80.3 KB
/
Copy pathsetup-environment.sh
File metadata and controls
executable file
·1770 lines (1623 loc) · 80.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
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/bash
# Configuration
ENV_FILE=".env"
CONFIG_FILE="config.yml"
DATA_DIR="data"
HB_API_DIR="../hummingbot-api"
HB_API_REPO="https://github.com/hummingbot/hummingbot-api.git"
# ── Colors & Output Helpers ──────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
RESET='\033[0m'
msg_info() { echo -e " ${CYAN}→${RESET} $1"; }
msg_ok() { echo -e " ${GREEN}✓${RESET} $1"; }
msg_warn() { echo -e " ${YELLOW}!${RESET} $1"; }
msg_error() { echo -e " ${RED}✗${RESET} $1"; }
# Prompt with default value display
prompt_visible() {
local prompt="$1"
local default="$2"
local var_name="$3"
if [ -n "$default" ]; then
echo -ne " ${prompt} ${DIM}[${default}]${RESET}: " >&2
else
echo -ne " ${prompt}: " >&2
fi
read -r value < /dev/tty || value=""
value=$(echo "$value" | tr -d '[:space:]')
if [ -z "$value" ] && [ -n "$default" ]; then
value="$default"
fi
eval "$var_name=\"$value\""
}
# Prompt for passwords (no echo). Never echoes ``default`` in cleartext --
# unlike prompt_visible, a bracketed hint here would defeat the whole point
# of a masked prompt the moment a caller passed one.
#
# Only the *edges* are trimmed. prompt_visible strips all whitespace, which is
# right for a token or a hostname but wrong for a password: the user would
# type "correct horse battery", get "correcthorsebattery" written to two
# different .env files, and then be unable to log in anywhere with the
# passphrase they believe they set. Inner whitespace is rejected instead --
# see prompt_required_secret -- because .env is read by three different
# parsers (python-dotenv, docker compose, and `source`) that do not agree on
# how to quote it.
prompt_secret() {
local prompt="$1"
local default="$2"
local var_name="$3"
if [ -n "$default" ]; then
echo -ne " ${prompt} ${DIM}[hidden]${RESET}: " >&2
else
echo -ne " ${prompt}: " >&2
fi
read -rs value < /dev/tty || value=""
echo "" >&2
value="$(printf '%s' "$value" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
if [ -z "$value" ] && [ -n "$default" ]; then
value="$default"
fi
eval "$var_name=\"$value\""
}
# Prompt visibly, looping until non-empty. No default -- for values (like
# credentials) that must never silently fall back to something guessable.
prompt_required_visible() {
local prompt="$1" var_name="$2" warn_msg="${3:-This cannot be empty}"
while true; do
prompt_visible "$prompt" "" "$var_name"
if [ -z "${!var_name}" ]; then
msg_warn "$warn_msg"
continue
fi
break
done
}
# Same as prompt_required_visible, but masked (see prompt_secret).
prompt_required_secret() {
local prompt="$1" var_name="$2" warn_msg="${3:-This cannot be empty}"
while true; do
prompt_secret "$prompt" "" "$var_name"
if [ -z "${!var_name}" ]; then
msg_warn "$warn_msg"
continue
fi
if [[ "${!var_name}" =~ [[:space:]] ]]; then
msg_warn "Spaces are not supported in this value -- please retype it without any"
continue
fi
break
done
}
# Escape special characters for .env file
escape_env_value() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
value="${value//\$/\\\$}"
echo "$value"
}
# Set (or replace) KEY=VALUE in .env, creating the file if it does not exist.
set_env_var() {
local key="$1"
local value
value="$(escape_env_value "$2")"
if [ ! -f "$ENV_FILE" ]; then
touch "$ENV_FILE"
# .env holds the bot token and the API password. 644 (the usual umask
# default) means every other account on a shared box or VPS can read
# them. Best-effort: a no-op on filesystems without POSIX modes.
chmod 600 "$ENV_FILE" 2>/dev/null || true
fi
if grep -q "^${key}=" "$ENV_FILE"; then
sed -i.bak "s|^${key}=.*|${key}=${value}|" "$ENV_FILE"
rm -f "$ENV_FILE.bak"
else
echo "${key}=${value}" >> "$ENV_FILE"
fi
}
# Read one KEY out of .env *without executing the file*.
#
# `source` runs .env as a shell script, so one value containing shell
# metacharacters (an API key with parentheses is enough) is a syntax error that
# aborts the read and silently drops every variable below that line. That is how
# a hand-added CONDOR_MODE=local could become invisible here — and then be
# rewritten to `telegram` by the legacy-install migration below. The variables
# that decide the mode are read with this instead.
read_env_var() {
local key="$1" line
[ -f "$ENV_FILE" ] || return 0
line="$(grep -E "^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=" "$ENV_FILE" 2>/dev/null | tail -1)" || return 0
[ -n "$line" ] || return 0
line="${line#*=}"
line="$(printf '%s' "$line" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
case "$line" in
\"*\") line="${line%\"}"; line="${line#\"}" ;;
\'*\') line="${line%\'}"; line="${line#\'}" ;;
esac
printf '%s' "$line"
}
# What local mode costs, said out loud. It is a dashboard with no login at all,
# so the only thing standing between it and the network is the loopback bind.
local_mode_warning() {
echo ""
msg_warn "Local mode has NO login — whoever reaches the dashboard controls your funds"
echo -e " • It binds ${BOLD}127.0.0.1${RESET} only: reachable from this machine, nowhere else."
echo -e " • Exposing it takes an explicit ${BOLD}WEB_HOST=0.0.0.0${RESET} in .env, which puts full"
echo -e " trading control in reach of anyone who can hit the port. Only do that behind"
echo -e " something that authenticates (Tailscale, an SSH tunnel, a reverse proxy)."
echo -e " • Dashboard: ${BOLD}http://localhost:8088${RESET}"
echo ""
}
# OSC 8 clickable hyperlink (falls back to plain URL)
make_link() {
local url="$1"
local text="${2:-$url}"
# Check if terminal supports hyperlinks (most modern terminals do)
if [ -n "${TERM:-}" ] && [ "${TERM:-}" != "dumb" ]; then
echo -e "\033]8;;${url}\033\\${text}\033]8;;\033\\"
else
echo "$text ($url)"
fi
}
# Refresh PATH to include common installation locations
refresh_path() {
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:/usr/local/bin:$PATH"
# Load nvm if available
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
# Add nvm node to PATH if nvm is available
if command_exists nvm; then
export PATH="$NVM_DIR/versions/node/$(nvm version 2>/dev/null)/bin:$PATH"
fi
# Also source profile files if they exist
[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" 2>/dev/null || true
[ -f "$HOME/.profile" ] && source "$HOME/.profile" 2>/dev/null || true
[ -f "$HOME/.bash_profile" ] && source "$HOME/.bash_profile" 2>/dev/null || true
}
# Check if a command exists and is executable
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# Quick localhost API probe — avoid hanging Step 3 when port 8000 is slow/unreachable
api_health_check() {
curl -sf --connect-timeout 3 --max-time 5 http://localhost:8000/docs >/dev/null 2>&1
}
# Can we actually reach and log into the API we were just told to use?
#
# Checked before setup reports success, because every way this goes wrong
# produces a symptom that points somewhere else:
#
# no connection hummingbot-api publishes port 8000 on 127.0.0.1 by
# default, so a correct install on another machine is
# unreachable until API_BIND is widened there -- and nothing
# in either installer mentions that at the point you choose
# a remote API.
# 401 the host answered, but not with these credentials. On a
# tailnet that usually means a name collision: some OTHER
# machine is running hummingbot-api under the name we asked
# for.
#
# Echoes one of: ok | unauthorized | unreachable
api_probe() {
local proto="$1" host="$2" port="$3" user="$4" pass="$5" code
command_exists curl || { echo ok; return; }
code="$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 --max-time 10 \
-u "$user:$pass" "$proto://$host:$port/accounts/" 2>/dev/null)" || code=000
case "$code" in
2*) echo ok ;;
401|403) echo unauthorized ;;
000) echo unreachable ;;
*) echo ok ;; # 404/500 etc: it is there and it let us in
esac
}
# Report an api_probe result, with the diagnosis each failure actually needs.
# Never fatal: a VPS that is not up yet is a normal reason to finish setup and
# fix the connection afterwards.
api_probe_report() {
local proto="$1" host="$2" port="$3" user="$4" pass="$5" tailnet="${6:-false}"
msg_info "Checking the API at $proto://$host:$port ..."
case "$(api_probe "$proto" "$host" "$port" "$user" "$pass")" in
ok)
msg_ok "API reachable and credentials accepted"
;;
unauthorized)
msg_warn "$host:$port answered, but rejected these credentials (401)."
if [ "$tailnet" = true ]; then
msg_info "On a tailnet this usually means the name belongs to a DIFFERENT machine:"
msg_info "Tailscale appends -1, -2 ... when a hostname is taken, so the node you"
msg_info "deployed may be '$host-1' while '$host' is somebody else's."
msg_info "Check with: tailscale status"
fi
msg_info "Fix the host or the credentials in config.yml, or via /servers in Telegram."
;;
unreachable)
msg_warn "Could not connect to $host:$port."
case "$host" in
localhost|127.0.0.1|::1)
# Nothing to do with bind addresses: the stack is right
# here and simply is not up.
msg_info "That is this machine, so the API is just not running."
msg_info "Start it: cd $HB_API_DIR && make deploy"
;;
*)
msg_info "hummingbot-api publishes port 8000 on 127.0.0.1 by default, so a healthy"
msg_info "install is still unreachable from another machine until you widen it."
msg_info "On the machine running hummingbot-api:"
msg_info " add API_BIND=0.0.0.0 to its .env, then 'make deploy'"
msg_info " (or put both machines on a tailnet, which needs no open port)"
;;
esac
msg_info "Then re-check here with: make doctor"
;;
esac
}
# Restore Tailscale wizard choice from .env (survives re-runs after Step 1 is skipped)
load_tailscale_choice() {
case "${USE_TAILSCALE:-}" in
[Tt][Rr][Uu][Ee]|[Yy][Ee][Ss]|[Yy]|1) use_tailscale_early="y" ;;
esac
}
# True when config.yml already has a server host entry under servers:
config_has_api_server() {
[ -f "$CONFIG_FILE" ] && grep -A8 '^servers:' "$CONFIG_FILE" 2>/dev/null | grep -q 'host:'
}
# Merge API connection details into config.yml (handles both template and servers: {} layouts).
# PyYAML is NOT part of the Python standard library and is not guaranteed to be
# preinstalled on a fresh Linux/Mac/WSL box, so this tries several ways to get a
# working python3+yaml before falling back to a plain text edit. Returns 0 on a
# full YAML-aware update, 1 if it had to fall back to the degraded sed edit.
update_config_api_server() {
local host="$1" port="$2" username="$3" password="$4"
local yaml_script
yaml_script=$(cat << 'PYEOF'
import sys
from pathlib import Path
import yaml
path = Path(sys.argv[1])
host, port, username, password, admin_id = sys.argv[2:7]
data = yaml.safe_load(path.read_text()) if path.exists() else {}
if not isinstance(data, dict):
data = {}
servers = data.setdefault("servers", {})
servers["local"] = {
"host": host,
"port": int(port),
"username": username,
"password": password,
}
data["default_server"] = data.get("default_server") or "local"
server_access = data.setdefault("server_access", {})
if "local" not in server_access:
server_access["local"] = {
"owner_id": int(admin_id) if admin_id.isdigit() else admin_id,
"created_at": None,
"shared_with": {},
}
# Compare as strings since the YAML template stores this key as an int but
# admin_id arrives here as a string -- avoids writing a duplicate entry.
chat_defaults = data.setdefault("chat_defaults", {})
existing_keys = {str(k) for k in chat_defaults.keys()}
if admin_id and admin_id not in existing_keys:
chat_defaults[int(admin_id) if admin_id.isdigit() else admin_id] = "local"
data.setdefault("version", 1)
path.write_text(yaml.safe_dump(data, sort_keys=False, default_flow_style=False))
PYEOF
)
# 1. PyYAML already importable by the system python3 -- use it directly.
if python3 -c "import yaml" >/dev/null 2>&1; then
echo "$yaml_script" | python3 - "$CONFIG_FILE" "$host" "$port" "$username" "$password" "$ADMIN_USER_ID" && return 0
fi
# 2. uv is already installed by Step 0 of this script -- use it to run the
# editor in an ephemeral environment with pyyaml. No system/user
# site-packages changes, no PEP 668 "externally managed" errors.
if command_exists uv; then
if echo "$yaml_script" | uv run --with pyyaml python3 - "$CONFIG_FILE" "$host" "$port" "$username" "$password" "$ADMIN_USER_ID" 2>/dev/null; then
return 0
fi
fi
# 3. Last resort: try installing pyyaml directly for python3 (covers both
# PEP 668 "externally managed" distros and plain ones, root or non-root).
msg_warn "PyYAML unavailable via uv -- attempting a direct pip install..."
if python3 -m pip install --user --quiet pyyaml >/dev/null 2>&1 \
|| python3 -m pip install --user --break-system-packages --quiet pyyaml >/dev/null 2>&1 \
|| python3 -m pip install --break-system-packages --quiet pyyaml >/dev/null 2>&1; then
if python3 -c "import yaml" >/dev/null 2>&1; then
echo "$yaml_script" | python3 - "$CONFIG_FILE" "$host" "$port" "$username" "$password" "$ADMIN_USER_ID" && return 0
fi
fi
# 4. All YAML-based paths failed -- fall back to the old text-based edit so
# config.yml still gets updated, just without the richer server_access /
# chat_defaults handling.
msg_warn "Could not load PyYAML -- falling back to a simpler text-based edit of $CONFIG_FILE"
sed -i.bak "/servers:/,/^[^ ]/ s/host: .*/host: $host/" "$CONFIG_FILE" && rm -f "$CONFIG_FILE.bak"
sed -i.bak "/servers:/,/^[^ ]/ s/port: .*/port: $port/" "$CONFIG_FILE" && rm -f "$CONFIG_FILE.bak"
if [ -n "$username" ]; then
sed -i.bak "/servers:/,/^[^ ]/ s/username: .*/username: $username/" "$CONFIG_FILE" && rm -f "$CONFIG_FILE.bak"
sed -i.bak "/servers:/,/^[^ ]/ s/password: .*/password: $password/" "$CONFIG_FILE" && rm -f "$CONFIG_FILE.bak"
fi
return 1
}
# Does the tailnet device exist on this host?
#
# /sys/class/net is present on every Linux kernel and reading it needs no
# iproute2, so a host without `ip` still gets a real answer rather than the
# permissive one -- previously a missing `ip` read as "device free" and sent
# a caller straight into the collision this is meant to detect.
#
# No /sys and no tools means this is not Linux, and a network_mode: host
# container cannot hold the host's device there in the first place, so
# "absent" is the correct answer rather than a guess.
tailnet_device_present() {
[ -e /sys/class/net/tailscale0 ] && return 0
command_exists ip && ip link show tailscale0 >/dev/null 2>&1 && return 0
command_exists ifconfig && ifconfig tailscale0 >/dev/null 2>&1 && return 0
return 1
}
# The name a node is ACTUALLY registered under on the tailnet.
#
# Tailscale suffixes a hostname that is already taken: ask for
# "hummingbot-api" on a tailnet that already has one -- a colleague's box, an
# earlier install, a machine someone rebuilt -- and this node comes up as
# "hummingbot-api-1". The requested name then belongs to somebody ELSE, and
# because their machine is very likely running the same software it answers,
# so the mistake surfaces as "401 Incorrect username or password" and sends
# people to check a password that was never wrong.
#
# `--self=true --peers=false` prints exactly one line whose second field is the
# assigned DNS label. No JSON parser needed, which matters inside the sidecar
# image (no python, no jq).
#
# Pass the command that reaches the right daemon:
# tailnet_node_name # this host
# tailnet_node_name docker exec hummingbot-tailscale tailscale # sidecar
tailnet_node_name() {
local line
if [ "$#" -eq 0 ]; then
set -- tailscale
fi
line="$("$@" status --self=true --peers=false 2>/dev/null | grep -v '^[[:space:]]*$' | head -1)" || return 1
[ -n "$line" ] || return 1
printf '%s' "$line" | awk '{print $2}'
}
# Peers on this tailnet whose name looks like a hummingbot-api node, newline
# separated. Matches the requested name and the -1/-2 suffixes Tailscale adds,
# so the machine we are looking for is found under whichever one it got.
tailnet_api_peers() {
local want="${1:-hummingbot-api}"
tailscale status --peers 2>/dev/null \
| awk '{print $2}' \
| grep -E "^${want}(-[0-9]+)?$" || true
}
# Make sure a tailscaled is running, then `tailscale up`.
#
# Ask the service manager first, ALWAYS -- including on WSL2, which has shipped
# with systemd on by default since 0.67 and which the packaged tailscale
# installs a unit for. This used to branch straight to a hand-started daemon
# whenever /proc/version mentioned microsoft, and that daemon is a background
# child of this script with no nohup and no setsid: it died with the
# installer's shell, taking tailscale0 and the serve config with it, while
# setup had already printed "Tailscale connected" and written a tailnet
# dashboard URL. It also left tailscaled.service sitting there inactive and
# never enabled, so nothing brought it back on reboot either.
#
# That matters more than it used to: the tailnet work now resolves most
# machines to mode=host, which means `make deploy` -- today's and every future
# one -- depends on this daemon still being here.
tailscale_up() {
if ! pgrep -x tailscaled >/dev/null 2>&1; then
if command_exists systemctl && systemctl list-unit-files tailscaled.service >/dev/null 2>&1; then
msg_info "Starting the tailscaled service..."
# --now starts it; enable is what survives a reboot.
sudo systemctl enable --now tailscaled >/dev/null 2>&1 || \
sudo systemctl start tailscaled >/dev/null 2>&1 || true
# The socket appears a moment after the unit reports active.
for _ in 1 2 3 4 5 6 7 8 9 10; do
pgrep -x tailscaled >/dev/null 2>&1 && break
sleep 1
done
fi
fi
if ! pgrep -x tailscaled >/dev/null 2>&1; then
# No service manager, or no unit for it. Start it ourselves -- but
# detached, so it outlives this script's terminal instead of dying
# with it.
msg_info "Starting the Tailscale daemon..."
sudo mkdir -p /var/run/tailscale /var/lib/tailscale
# The redirection has to happen INSIDE sudo. Written as
# `sudo tailscaled ... >/var/log/tailscaled.log` the calling shell
# opens that path as the invoking user, which fails on any normal
# box before sudo runs at all -- and the daemon then never starts,
# silently, which is the failure this whole function exists to avoid.
# setsid detaches it from this terminal so it outlives the installer.
sudo sh -c 'setsid tailscaled --state=/var/lib/tailscale/tailscaled.state \
--socket=/var/run/tailscale/tailscaled.sock \
>>/var/log/tailscaled.log 2>&1 </dev/null &'
sleep 2
# Say it out loud rather than leaving someone to discover it after a
# reboot: nothing here arranges for this daemon to come back.
msg_warn "Started without a service manager — this daemon will not restart on boot."
msg_info "Re-run 'sudo tailscaled ... &' and 'sudo tailscale up' after a restart,"
msg_info "or install a service for it."
fi
sudo tailscale up "$@"
}
# ── Banner ───────────────────────────────────────────
echo ""
echo -e "${BOLD}╔═══════════════════════════════════════════╗${RESET}"
echo -e "${BOLD}║ Condor Setup ║${RESET}"
echo -e "${BOLD}╚═══════════════════════════════════════════╝${RESET}"
echo ""
# ── Step 0: Ensure dependencies are installed ───────
echo -e "${BOLD}Step 0: Installing Dependencies${RESET}"
echo ""
SUDO_CMD=""
if [ "${EUID:-0}" -ne 0 ] && command_exists sudo; then
SUDO_CMD="sudo"
fi
# Track if we need to restart the script
NEEDS_RESTART=false
# ── Install uv ──────────────────────────────────────
if ! command_exists uv; then
msg_info "Installing uv (https://docs.astral.sh/uv/)..."
if curl -LsSf https://astral.sh/uv/install.sh | sh; then
msg_ok "uv installed successfully"
NEEDS_RESTART=true
refresh_path
# Verify installation
if ! command_exists uv; then
msg_warn "uv installed but not immediately available. Will retry after PATH refresh."
else
msg_ok "uv is now available"
fi
else
msg_error "Failed to install uv automatically."
msg_info "Please install manually: curl -LsSf https://astral.sh/uv/install.sh | sh"
exit 1
fi
else
msg_ok "uv is already installed ($(uv --version 2>/dev/null || echo 'version unknown'))"
fi
# ── Install tmux ────────────────────────────────────
if ! command_exists tmux; then
msg_info "Installing tmux..."
if command_exists apt-get; then
$SUDO_CMD apt-get update -qq && $SUDO_CMD apt-get install -y tmux || {
msg_error "Failed to install tmux via apt-get."
exit 1
}
elif command_exists yum; then
$SUDO_CMD yum install -y tmux || {
msg_error "Failed to install tmux via yum."
exit 1
}
elif command_exists dnf; then
$SUDO_CMD dnf install -y tmux || {
msg_error "Failed to install tmux via dnf."
exit 1
}
elif command_exists brew; then
brew install tmux || {
msg_error "Failed to install tmux via Homebrew."
exit 1
}
else
msg_error "No supported package manager found. Please install tmux manually."
exit 1
fi
msg_ok "tmux installed successfully"
else
msg_ok "tmux is already installed ($(tmux -V 2>/dev/null || echo 'version unknown'))"
fi
# ── Install Node.js and npm via nvm ────────────────
if ! command_exists node || ! command_exists npm; then
# If node exists but npm doesn't, we still need to install via nvm
if command_exists node && ! command_exists npm; then
msg_warn "Node.js found but npm is missing. Installing via nvm..."
else
msg_info "Installing Node.js and npm via nvm..."
fi
# Install nvm (Node Version Manager)
if [ ! -d "$HOME/.nvm" ]; then
msg_info "Installing nvm..."
if curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash; then
msg_ok "nvm installed successfully"
else
msg_error "Failed to install nvm."
msg_info "Install manually: https://github.com/nvm-sh/nvm"
exit 1
fi
else
msg_ok "nvm is already installed"
fi
# Load nvm into current shell (in lieu of restarting)
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
# Verify nvm is loaded
if ! command_exists nvm; then
msg_warn "nvm not immediately available, sourcing profile..."
# Try to source nvm from common locations
for profile in "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.zshrc" "$HOME/.profile"; do
if [ -f "$profile" ] && grep -q "NVM_DIR" "$profile"; then
source "$profile" 2>/dev/null || true
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
break
fi
done
fi
# Install Node.js LTS (version 24)
msg_info "Installing Node.js v24 (LTS)..."
if nvm install 24; then
nvm use 24
nvm alias default 24
msg_ok "Node.js v24 installed and set as default"
NEEDS_RESTART=true
else
msg_error "Failed to install Node.js via nvm."
exit 1
fi
# Load node into current shell
export PATH="$NVM_DIR/versions/node/$(nvm version)/bin:$PATH"
else
msg_ok "Node.js is already installed ($(node --version 2>/dev/null))"
msg_ok "npm is available ($(npm --version 2>/dev/null))"
fi
# Verify npm is available - final check with better error handling
if ! command_exists npm; then
msg_warn "npm not found, attempting to load nvm..."
# Try to load nvm and node
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
if command_exists nvm; then
nvm use default 2>/dev/null || nvm use node 2>/dev/null || nvm use 24 2>/dev/null || true
export PATH="$NVM_DIR/versions/node/$(nvm version)/bin:$PATH"
fi
# Final check
if ! command_exists npm; then
msg_error "npm still not available."
msg_info "Your system has Node.js $(node --version 2>/dev/null) installed without npm."
msg_info ""
msg_info "Options:"
msg_info " 1. Install npm: sudo apt-get install npm (or your package manager)"
msg_info " 2. Or let this script install via nvm (uninstall system node first)"
msg_info ""
msg_info "To use nvm: sudo apt-get remove nodejs && bash $0"
exit 1
fi
fi
# ── Install TypeScript globally ─────────────────────
if ! command_exists tsc && ! npm list -g typescript >/dev/null 2>&1; then
msg_info "Installing TypeScript globally..."
if npm install -g typescript; then
msg_ok "TypeScript installed successfully"
NEEDS_RESTART=true
refresh_path
else
msg_error "Failed to install TypeScript globally."
msg_info "You can install it later with: npm install -g typescript"
# Don't exit - TypeScript might not be critical for all setups
fi
else
msg_ok "TypeScript is already installed ($(tsc --version 2>/dev/null || echo 'installed'))"
fi
# ── Handle script restart if needed ─────────────────
if [ "$NEEDS_RESTART" = true ]; then
msg_info "Dependencies were installed. Refreshing environment..."
refresh_path
# Verify critical commands are now available
missing_commands=()
command_exists uv || missing_commands+=("uv")
command_exists node || missing_commands+=("node")
command_exists npm || missing_commands+=("npm")
if [ ${#missing_commands[@]} -gt 0 ]; then
msg_warn "Some commands still not available: ${missing_commands[*]}"
msg_info "Restarting script with refreshed environment..."
echo ""
# Re-execute this script in a new shell with proper environment
exec bash "$0" "$@"
else
msg_ok "All dependencies are now available!"
fi
fi
echo ""
# ── Step 1: Run mode (Telegram or local) ────────────
echo -e "${BOLD}Step 1: How will you use Condor?${RESET}"
echo ""
telegram_configured=false
# Source existing .env if present. Best-effort only: the rest of the wizard
# reads incidental values (DEPLOY_HUMMINGBOT_API, CONDOR_DEFAULT_AGENT, the
# Tailscale settings) from here, but nothing that decides the *mode* — those are
# re-read below with read_env_var, which cannot be defeated by one unquoted
# value halfway down the file.
if [ -f "$ENV_FILE" ]; then
set -a
source "$ENV_FILE" 2>/dev/null
set +a
fi
if [ -f "$ENV_FILE" ]; then
CONDOR_MODE="$(read_env_var CONDOR_MODE)"
TELEGRAM_TOKEN="$(read_env_var TELEGRAM_TOKEN)"
ADMIN_USER_ID="$(read_env_var ADMIN_USER_ID)"
fi
# An install from before local mode existed has a token but no CONDOR_MODE.
# That is telegram — recorded now, so the mode is always explicit on disk and
# is never inferred later from whether a token happens to be there.
if [ -z "${CONDOR_MODE:-}" ] && [ -n "${TELEGRAM_TOKEN:-}" ] && [ -n "${ADMIN_USER_ID:-}" ]; then
CONDOR_MODE="telegram"
set_env_var CONDOR_MODE "telegram"
fi
# A configured install is *offered the other mode*, never just told it is
# already configured. Switching is the one thing a re-run has to be able to do:
# the boot errors for a broken mode all say "run make setup", and that has to be
# advice that leads somewhere.
choose_mode() {
echo -e " ${BOLD}1) Telegram${RESET} — control Condor from a Telegram bot (recommended)"
echo -e " ${BOLD}2) Local${RESET} — no Telegram; the dashboard runs on this machine, no login"
echo ""
while true; do
prompt_visible "Choose [1/2]" "$1" "condor_mode_choice"
case "${condor_mode_choice:-}" in
1|2) break ;;
*) msg_warn "Enter 1 (Telegram) or 2 (Local)" ;;
esac
done
echo ""
}
if [ "${CONDOR_MODE:-}" = "local" ]; then
msg_ok "Currently: Local mode — dashboard only, no Telegram"
echo ""
prompt_visible "Keep local mode? [Y/n]" "Y" "keep_mode"
echo ""
if [[ "${keep_mode:-Y}" =~ ^[Nn]$ ]]; then
choose_mode "1"
else
condor_mode_choice=2
fi
elif [ -n "${TELEGRAM_TOKEN:-}" ] && [ -n "${ADMIN_USER_ID:-}" ]; then
msg_ok "Currently: Telegram (bot configured, admin ${ADMIN_USER_ID})"
echo ""
prompt_visible "Keep Telegram mode? [Y/n]" "Y" "keep_mode"
echo ""
if [[ "${keep_mode:-Y}" =~ ^[Nn]$ ]]; then
choose_mode "2"
else
telegram_configured=true
load_tailscale_choice
fi
else
choose_mode "1"
fi
if [ "${condor_mode_choice:-}" = "2" ]; then
# Local mode: nothing to ask about a bot. No Telegram, no token — the
# dashboard logs in as ADMIN_USER_ID, the same id config.yml, chat
# defaults, preferences, memory and session keys already key on.
#
# An existing numeric id is KEPT, never overwritten with 1: a Telegram
# install switching to local keeps its own admin, so the dashboard logs in
# as you with every server, preference and default intact. 1 is only the
# answer for an install that never had a Telegram id.
CONDOR_MODE="local"
if [[ "${ADMIN_USER_ID:-}" =~ ^[1-9][0-9]*$ ]]; then
msg_info "Local mode will log in as user ${ADMIN_USER_ID} (ADMIN_USER_ID in .env)"
else
ADMIN_USER_ID="1"
set_env_var ADMIN_USER_ID "1"
fi
set_env_var CONDOR_MODE "local"
set_env_var WEB_URL "http://localhost:8088"
msg_ok ".env configured for local mode"
local_mode_warning
# Tailscale is not just an option here — it is the answer to the warning
# above: an unauthenticated dashboard is only as safe as what can actually
# reach it. Asked the same way Telegram mode asks it (below), so local
# mode gets the identical Tailscale install/connect treatment in Step 3
# for hummingbot-api — and for the dashboard itself, main.py's _run_dual()
# binds loopback and proxies it via `tailscale serve` whenever
# USE_TAILSCALE is set, local mode or not.
prompt_visible "Use Tailscale to secure the dashboard and the hummingbot-api connection? [y/N]" "N" "use_tailscale_early"
if [[ "${use_tailscale_early:-}" =~ ^[Yy]$ ]]; then
USE_TAILSCALE=true
else
USE_TAILSCALE=false
fi
set_env_var USE_TAILSCALE "$USE_TAILSCALE"
elif [ "${condor_mode_choice:-}" = "1" ]; then
msg_info "Create a bot: $(make_link 'https://t.me/BotFather')"
msg_info "Get your ID: $(make_link 'https://t.me/userinfobot')"
echo ""
# Prompt for Telegram Bot Token
while true; do
prompt_visible "Telegram Bot Token" "" "telegram_token"
if [ -z "$telegram_token" ]; then
msg_warn "Token cannot be empty"
continue
fi
if ! [[ "$telegram_token" =~ ^[0-9]+:[A-Za-z0-9_-]+$ ]]; then
msg_warn "Invalid format. Expected: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
continue
fi
break
done
TELEGRAM_TOKEN="$telegram_token"
# Prompt for Admin User ID
while true; do
prompt_visible "Admin User ID" "" "admin_id"
if [ -z "$admin_id" ]; then
msg_warn "User ID cannot be empty"
continue
fi
if ! [[ "$admin_id" =~ ^[0-9]+$ ]]; then
msg_warn "User ID must be numeric (e.g., 123456789)"
continue
fi
break
done
ADMIN_USER_ID="$admin_id"
# Determine web dashboard access
echo ""
prompt_visible "Will you use Tailscale to secure the connection to hummingbot-api? [y/N]" "N" "use_tailscale_early"
SERVER_IP=""
if ! [[ "${use_tailscale_early:-}" =~ ^[Yy]$ ]]; then
echo ""
echo -e " The /web Telegram command sends you a login link for the web dashboard."
echo -e " So we need to know where Condor is running:"
echo -e " ${BOLD}Local machine${RESET} (Mac / Linux / WSL2) — press Enter"
echo -e " ${BOLD}Remote VPS${RESET} — enter the server's public IP"
prompt_visible "Public IP or hostname (press Enter for localhost)" "" "server_ip"
SERVER_IP="${server_ip:-}"
fi
if [[ "${use_tailscale_early:-}" =~ ^[Yy]$ ]]; then
USE_TAILSCALE=true
else
USE_TAILSCALE=false
fi
# Write .env (preserve extra vars if file exists)
if [ -f "$ENV_FILE" ]; then
# Update existing values
if grep -q "^TELEGRAM_TOKEN=" "$ENV_FILE"; then
sed -i.bak "s|^TELEGRAM_TOKEN=.*|TELEGRAM_TOKEN=$(escape_env_value "$TELEGRAM_TOKEN")|" "$ENV_FILE"
rm -f "$ENV_FILE.bak"
else
echo "TELEGRAM_TOKEN=$(escape_env_value "$TELEGRAM_TOKEN")" >> "$ENV_FILE"
fi
if grep -q "^ADMIN_USER_ID=" "$ENV_FILE"; then
sed -i.bak "s|^ADMIN_USER_ID=.*|ADMIN_USER_ID=$(escape_env_value "$ADMIN_USER_ID")|" "$ENV_FILE"
rm -f "$ENV_FILE.bak"
else
echo "ADMIN_USER_ID=$(escape_env_value "$ADMIN_USER_ID")" >> "$ENV_FILE"
fi
# Add WEB_URL if server IP was provided
if [ -n "$SERVER_IP" ]; then
if grep -q "^WEB_URL=" "$ENV_FILE"; then
sed -i.bak "s|^WEB_URL=.*|WEB_URL=http://$(escape_env_value "$SERVER_IP"):8088|" "$ENV_FILE"
rm -f "$ENV_FILE.bak"
else
echo "WEB_URL=http://$(escape_env_value "$SERVER_IP"):8088" >> "$ENV_FILE"
fi
fi
if grep -q "^USE_TAILSCALE=" "$ENV_FILE"; then
sed -i.bak "s|^USE_TAILSCALE=.*|USE_TAILSCALE=$USE_TAILSCALE|" "$ENV_FILE"
rm -f "$ENV_FILE.bak"
else
echo "USE_TAILSCALE=$USE_TAILSCALE" >> "$ENV_FILE"
fi
else
{
echo "TELEGRAM_TOKEN=$(escape_env_value "$TELEGRAM_TOKEN")"
echo "ADMIN_USER_ID=$(escape_env_value "$ADMIN_USER_ID")"
echo "USE_TAILSCALE=$USE_TAILSCALE"
if [ -n "$SERVER_IP" ]; then
echo "WEB_URL=http://$(escape_env_value "$SERVER_IP"):8088"
fi
} > "$ENV_FILE"
chmod 600 "$ENV_FILE" 2>/dev/null || true
fi
# The mode is written explicitly even for the default, so nothing downstream
# has to guess it from the presence of a token.
CONDOR_MODE="telegram"
set_env_var CONDOR_MODE "telegram"
msg_ok ".env created"
if [ -n "$SERVER_IP" ]; then
msg_ok "Web server configured: http://$SERVER_IP:8088"
fi
telegram_configured=true
fi
echo ""
# ── Step 2: AI Model (LLM) ──────────────────────────
echo -e "${BOLD}Step 2: AI Model (LLM)${RESET}"
echo ""
# The wizard (condor.setup_llm) renders the same readiness probes the bot
# uses, so it has to run in the project's Python env. Sync it FIRST, before
# asking anything below -- otherwise `uv run`'s own venv-creation/install
# output shows up sandwiched between "Pick an AI model now?" and the actual
# model menu, which reads as the prompt getting interrupted mid-conversation
# rather than as one continuous step. Unconditional (not gated behind the
# Y/n) since both branches below need it: picking a model runs the wizard
# directly, and skipping still needs it synced for `--status`/no-tty.
msg_info "Setting up Condor's Python environment (~250MB first run, 1-3 min)..."
uv run python -c "pass"
echo ""
if (: </dev/tty) 2>/dev/null; then
prompt_visible "Pick an AI model now? [Y/n]" "Y" "pick_model_now"
if [[ "${pick_model_now:-Y}" =~ ^[Nn]$ ]]; then
msg_ok "Skipped -- run 'make pick-model' any time"
else
# The venv is already warm above, so this goes straight to the model
# menu -- and if the model picked (or kept) needs a CLI bridge that
# isn't installed yet (e.g. `npm install -g @google/gemini-cli`),
# installs and confirms it right here, no separate pass afterward.
uv run python -m condor.setup_llm < /dev/tty || \
msg_warn "Model selection did not complete -- run 'make pick-model' later"
fi
else
msg_info "No terminal available -- skipping model selection"
uv run python -m condor.setup_llm --status || true
fi
echo ""
# ── Step 3: Hummingbot API ──────────────────────────
echo -e "${BOLD}Step 3: Hummingbot API${RESET}"
echo ""
hb_api_deployed=false
# Configured is not the same as answering; the summary must not conflate them.
hb_api_healthy=false
# Source .env again to get latest values
if [ -f "$ENV_FILE" ]; then
set -a
source "$ENV_FILE" 2>/dev/null
set +a
fi
load_tailscale_choice
finish_remote_api=false
if [ -n "${DEPLOY_HUMMINGBOT_API:-}" ]; then
if [ "${DEPLOY_HUMMINGBOT_API:-}" = "true" ]; then
msg_ok "Hummingbot API already configured (enabled)"
hb_api_deployed=true
elif config_has_api_server; then
msg_ok "Hummingbot API already configured (skipped)"
else
msg_info "Finishing remote API configuration..."
finish_remote_api=true
fi
fi
if [ -z "${DEPLOY_HUMMINGBOT_API:-}" ] || [ "$finish_remote_api" = true ]; then
# Check if a Hummingbot API is already running on port 8000
existing_api=false
msg_info "Checking for an existing API on localhost:8000..."
if api_health_check; then
existing_api=true
msg_warn "Hummingbot API already running on localhost:8000"
echo ""
prompt_visible "An API instance is already running. Override it? [y/N]" "N" "override_api"
if [[ "${override_api:-}" =~ ^[Yy]$ ]]; then
msg_info "Will reconfigure and restart the API."
else
set_env_var DEPLOY_HUMMINGBOT_API "false"