-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtnode-setup.sh
More file actions
executable file
·11087 lines (9925 loc) · 418 KB
/
tnode-setup.sh
File metadata and controls
executable file
·11087 lines (9925 loc) · 418 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
#!/usr/bin/env bash
# tnode-setup.sh — Configura un nodo completo para TNode en un solo comando.
#
# Instala y configura: Ollama + modelo LLM, OpenClaw, Cloudflare Tunnel (o Tailscale),
# tnode-qr y pair-watch (auto-approve pairing).
#
# Usage:
# curl -fsSL https://install.tbrain.app | bash
# bash tnode-setup.sh [OPTIONS]
#
# Options:
# --yes, -y Non-interactive (accept all defaults)
# --no-ollama Skip Ollama installation
# --no-tunnel Skip Cloudflare Tunnel (use --with-tailscale instead)
# --with-tailscale Install Tailscale alongside or instead of tunnel
# --no-tailscale Alias for default (Tailscale not installed by default)
# --tunnel-token T Pre-provisioned Cloudflare tunnel token
# --no-qr Skip QR display at the end
# --model <name> LLM model (default: auto-detect GPU → qwen3.5 / CPU → qwen3:1.7b)
# --cloud Use cloud model (kimi-k2.5:cloud) instead of local
# --verbose Enable debug output
# --version Print version and exit
set -euo pipefail
# ─────────────────────────────────────────────
# Auto-pair mode (cloud-provisioned droplets)
# ─────────────────────────────────────────────
# When TNODE_AUTO_PAIR=1 is set (by cloud-init user_data written by the
# provisionTNode Cloud Function), the installer reuses the pre-supplied
# nodeId / nodeSecret / ownerUid instead of generating fresh ones and
# showing a QR. BYO installs leave TNODE_AUTO_PAIR unset and execute the
# legacy path unchanged.
#
# See: tnode_client/architecture_cloud_provisioning.md §8
if [[ "${TNODE_AUTO_PAIR:-}" == "1" ]]; then
: "${TNODE_NODE_ID:?TNODE_NODE_ID required when TNODE_AUTO_PAIR=1}"
: "${TNODE_NODE_SECRET:?TNODE_NODE_SECRET required when TNODE_AUTO_PAIR=1}"
: "${TNODE_OWNER_UID:?TNODE_OWNER_UID required when TNODE_AUTO_PAIR=1}"
: "${TNODE_OP_ID:?TNODE_OP_ID required when TNODE_AUTO_PAIR=1}"
: "${TNODE_PROGRESS_URL:?TNODE_PROGRESS_URL required when TNODE_AUTO_PAIR=1}"
fi
# Progress heartbeat — only emits in auto-pair mode; no-op for BYO.
# Signs the payload with HMAC-SHA256 of nodeSecret (same scheme the watcher
# uses elsewhere). The Cloud Function receiving these updates the
# `nodes/{nodeId}.provisioning.steps[]` doc so the mobile app timeline
# advances live.
report_progress_heartbeat() {
[[ "${TNODE_AUTO_PAIR:-}" == "1" ]] || return 0
local phase="${1:?phase required}"
local extra_json="${2:-}" # optional JSON object (as string, no outer quotes)
local body sig
if [[ -n "$extra_json" ]]; then
body=$(printf '{"nodeId":"%s","phase":"%s","opId":"%s","extra":%s}' \
"$TNODE_NODE_ID" "$phase" "$TNODE_OP_ID" "$extra_json")
else
body=$(printf '{"nodeId":"%s","phase":"%s","opId":"%s"}' \
"$TNODE_NODE_ID" "$phase" "$TNODE_OP_ID")
fi
sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$TNODE_NODE_SECRET" -hex | awk '{print $2}')
curl -fsS -X POST \
-H "Content-Type: application/json" \
-H "X-HMAC-SHA256: $sig" \
-H "X-TNode-OpId: $TNODE_OP_ID" \
-d "$body" "$TNODE_PROGRESS_URL" >/dev/null 2>&1 || true
}
# Emit an "install_started" heartbeat as the very first action after env
# validation, so the provisionTNode CF can tell the difference between
# "installer never ran" (cloud-init / curl failure) and "installer ran but
# died later" (apt / npm failure mid-way).
report_progress_heartbeat "install_started"
# ─────────────────────────────────────────────
# Constants
# ─────────────────────────────────────────────
# Default HOME when running under cloud-init / systemd (no user profile).
# `set -u` would otherwise fail the next `$HOME` expansion.
: "${HOME:=/root}"
export HOME
# Ensure common binary paths are available (critical for piped installs
# where the shell profile hasn't been sourced)
for _p in /opt/homebrew/bin /usr/local/bin "$HOME/.local/bin" "$HOME/bin" /usr/sbin; do
case ":$PATH:" in
*":$_p:"*) ;;
*) [[ -d "$_p" ]] && export PATH="$_p:$PATH" ;;
esac
done
unset _p
TNODE_SETUP_VERSION="1.24.2"
CLOUD_MODEL="kimi-k2.5:cloud"
# Pin OpenClaw to the last known-good release. v2026.4.25 introduced an
# auto-pair regression where the gateway responds 1008 to unknown devices
# even with a valid Ed25519 signature + master token, blocking cloud
# provisioning E2E. Override with `OPENCLAW_PIN_VERSION=` (empty) to take
# whatever is current.
OPENCLAW_PIN_VERSION="${OPENCLAW_PIN_VERSION-2026.5.19}"
TNODE_USER="tnode"
TNODE_HOME="" # set in setup_tnode_user()
OPENCLAW_HOME="" # set in setup_tnode_user()
TNODE_BIN="" # set in setup_tnode_user()
# API provider helpers (bash 3.2 compatible — no associative arrays)
api_provider_valid() {
case "$1" in groq|openrouter|together) return 0 ;; *) return 1 ;; esac
}
api_provider_base_url() {
case "$1" in
groq) echo "https://api.groq.com/openai/v1" ;;
openrouter) echo "https://openrouter.ai/api/v1" ;;
together) echo "https://api.together.xyz/v1" ;;
esac
}
api_provider_default_model() {
case "$1" in
groq) echo "groq/llama-3.3-70b-versatile" ;;
openrouter) echo "openrouter/anthropic/claude-3.5-haiku" ;;
together) echo "together/meta-llama/Llama-3.3-70B-Instruct-Turbo" ;;
esac
}
# ─────────────────────────────────────────────
# CLI flags (defaults)
# ─────────────────────────────────────────────
YES=0
NO_OLLAMA=0
NO_TUNNEL=0 # --no-tunnel: skip Cloudflare Tunnel
WITH_TAILSCALE=0 # --with-tailscale: install Tailscale (not default anymore)
NO_TAILSCALE=0 # legacy compat, same as default now
NO_QR=0
MODEL="" # empty = auto-detect (GPU → qwen3.5, CPU → qwen2.5:1.5b)
MODEL_EXPLICIT=0 # 1 if user passed --model
USE_CLOUD=0
USE_API=0 # 1 if --api flag set
API_PROVIDER="" # groq, openrouter, together
API_KEY="" # API key for external provider
TUNNEL_TOKEN="" # --tunnel-token: pre-provisioned Cloudflare tunnel token
TUNNEL_DOMAIN="" # set by phase_tunnel
VERBOSE=0
UPDATE_ONLY=0 # --update-only: refresh scripts/binaries, never rotate secrets
COMPONENT="" # --component <name>: only run install_<name> + verify, implies --update-only
NO_SMOKE_TEST=0 # --no-smoke-test: skip post-update verify_<X>.py (escape hatch)
UNINSTALL=0 # --uninstall: stop+remove local services and ~/.openclaw, NO server-side cleanup
PURGE_BINARIES=0 # --purge-binaries: also delete /usr/local/bin/cloudflared and /usr/bin/openclaw
# Components supported by --component=<name> dispatcher. Mirrored in
# install.tbrain.app/verify/verify_<id>.py. Keep in sync with both.
SUPPORTED_COMPONENTS=(
"openclaw-gateway"
"tnode-chat-sync"
"tnode-config-sync"
"tnode-telemetry"
"pair-watch"
"cloudflared"
)
# Tunnel provisioning API
TUNNEL_API_URL="https://api.tbrain.app/v1/tunnel/provision"
# Firebase Function that issues short-lived HMAC tokens. The shared HMAC
# secret never leaves Firebase Secret Manager + Worker env; the installer
# only ever sees a per-request signed token (expires in 300s, single-use
# nonce). Same pattern as `setupKey` for OpenRouter.
PROVISION_TOKEN_URL="https://us-central1-tbrain-platform-7fc1f.cloudfunctions.net/getProvisionToken"
# Firebase Functions for chat-sync (tnode-chat-sync watcher on this node
# mirrors conversations to Firestore so the mobile app never loses a turn
# when it's closed).
REGISTER_NODE_SYNC_URL="https://us-central1-tbrain-platform-7fc1f.cloudfunctions.net/registerNodeSync"
MINT_NODE_TOKEN_URL="https://us-central1-tbrain-platform-7fc1f.cloudfunctions.net/mintNodeToken"
# Firebase Function that hands the per-node OR apiKey to the on-node
# daemon when it receives apply_openrouter_key. HMAC-signed with the same
# per-node secret as mintNodeToken. Key is minted/topped-up from the app.
PULL_LLM_CONFIG_URL="https://us-central1-tbrain-platform-7fc1f.cloudfunctions.net/pullLLMConfig"
# ─────────────────────────────────────────────
# Colors
# ─────────────────────────────────────────────
if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then
BOLD='\033[1m'
GREEN='\033[38;2;0;229;204m'
YELLOW='\033[38;2;255;176;32m'
RED='\033[38;2;230;57;70m'
BLUE='\033[38;2;100;149;237m'
MUTED='\033[38;2;136;146;176m'
NC='\033[0m'
else
BOLD='' GREEN='' YELLOW='' RED='' BLUE='' MUTED='' NC=''
fi
# ─────────────────────────────────────────────
# Logging helpers
# ─────────────────────────────────────────────
info() { echo -e " ${MUTED}$*${NC}"; }
success() { echo -e " ${GREEN}✓${NC} $*"; }
warn() { echo -e " ${YELLOW}⚠${NC} $*"; }
fail() { echo -e " ${RED}✗${NC} $*" >&2; }
die() { fail "$*"; exit 1; }
phase() {
local num="$1"; shift
echo ""
echo -e " ${BOLD}[${num}]${NC} ${BOLD}$*${NC}"
}
# ─────────────────────────────────────────────
# Utility functions
# ─────────────────────────────────────────────
command_exists() { command -v "$1" >/dev/null 2>&1; }
# In --update-only mode, the systemd unit was provisioned during a prior
# full install and should NOT be regenerated — writing to /etc/systemd/system
# requires root, which fails on the Pi (tbrainadmin) and on VPSs when the
# operator runs `update.tbrain.app/update.sh | bash` as a non-root user.
# Each install_<comp>_systemd calls this at the top; if it returns 0, the
# rest of the function (the unit-file write + daemon-reload + enable) is
# skipped. Picks user vs system path automatically.
_systemd_update_only_handled() {
[[ "${UPDATE_ONLY:-0}" != "1" ]] && return 1
local unit="$1"
local user_path="${HOME}/.config/systemd/user/${unit}.service"
local sys_path="/etc/systemd/system/${unit}.service"
if [[ -f "$user_path" ]]; then
XDG_RUNTIME_DIR="/run/user/$(id -u)" systemctl --user daemon-reload 2>/dev/null || true
XDG_RUNTIME_DIR="/run/user/$(id -u)" systemctl --user restart "$unit" 2>/dev/null || true
success "systemd --user ${unit} reloaded+restarted (update-only)"
return 0
fi
if [[ -f "$sys_path" ]]; then
if sudo -n systemctl daemon-reload 2>/dev/null && sudo -n systemctl restart "$unit" 2>/dev/null; then
success "systemd ${unit} restarted via sudo (update-only)"
else
warn "systemd ${unit}: could not restart without sudo; run 'sudo systemctl restart ${unit}' manually"
fi
return 0
fi
return 1
}
# Run a command as the tnode user (no-op if already tnode or on macOS)
run_as_tnode() {
if [[ "$OS" == "Darwin" ]] || [[ "$(id -un)" == "$TNODE_USER" ]]; then
"$@"
else
# Set XDG_RUNTIME_DIR so systemctl --user works via su
local tnode_uid
tnode_uid="$(id -u "$TNODE_USER" 2>/dev/null || echo "")"
local env_prefix=""
if [[ -n "$tnode_uid" ]] && [[ -d "/run/user/$tnode_uid" ]]; then
env_prefix="export XDG_RUNTIME_DIR=/run/user/$tnode_uid; "
fi
su - "$TNODE_USER" -s /bin/bash -c "${env_prefix}$(printf '%q ' "$@")"
fi
}
# Create tnode user if on Linux and running as root
setup_tnode_user() {
if [[ "$OS" == "Darwin" ]]; then
# macOS: no user creation, use current user
TNODE_HOME="$HOME"
OPENCLAW_HOME="${OPENCLAW_HOME:-$TNODE_HOME/.openclaw}"
TNODE_BIN="$TNODE_HOME/bin"
return 0
fi
if [[ "$(id -u)" != "0" ]]; then
# Not root: use current user
TNODE_USER="$(id -un)"
TNODE_HOME="$HOME"
OPENCLAW_HOME="${OPENCLAW_HOME:-$TNODE_HOME/.openclaw}"
TNODE_BIN="$TNODE_HOME/bin"
return 0
fi
# Linux + root: create dedicated tnode user
if id "$TNODE_USER" >/dev/null 2>&1; then
success "Usuario $TNODE_USER ya existe"
else
useradd --create-home --shell /bin/bash "$TNODE_USER" 2>/dev/null || true
# Lock password (no direct login, use su from root)
passwd -l "$TNODE_USER" >/dev/null 2>&1 || true
success "Usuario $TNODE_USER creado (sin contraseña, acceso via: su - $TNODE_USER)"
fi
TNODE_HOME="$(eval echo "~$TNODE_USER")"
OPENCLAW_HOME="$TNODE_HOME/.openclaw"
TNODE_BIN="$TNODE_HOME/bin"
# Enable lingering so systemd --user services persist without SSH session
if command_exists loginctl; then
loginctl enable-linger "$TNODE_USER" 2>/dev/null || true
success "loginctl enable-linger $TNODE_USER (servicios persisten sin sesión)"
fi
# Ensure XDG_RUNTIME_DIR exists for tnode (needed by systemctl --user)
local tnode_uid
tnode_uid="$(id -u "$TNODE_USER" 2>/dev/null || echo "")"
if [[ -n "$tnode_uid" ]] && [[ ! -d "/run/user/$tnode_uid" ]]; then
mkdir -p "/run/user/$tnode_uid"
chown "$TNODE_USER":"$TNODE_USER" "/run/user/$tnode_uid"
chmod 700 "/run/user/$tnode_uid"
fi
# Start the user manager service so `systemctl --user` works from inside
# `su - tnode` without requiring a reboot. Without this, enable-linger
# only takes effect on next boot and the DBUS user bus returns
# "No medium found" until then.
if [[ -n "$tnode_uid" ]] && command_exists systemctl; then
systemctl start "user@${tnode_uid}.service" 2>/dev/null || true
fi
# Grant passwordless sudo so the agent (running as `tnode`) can perform
# privileged ops (systemctl, apt-get, writes under /etc/, etc.) without
# interactive prompts. Drop-in is atomic, validated via visudo, and
# removed by --uninstall.
local sudoers_file="/etc/sudoers.d/tnode"
local sudoers_tmp
sudoers_tmp="$(mktemp)"
echo "${TNODE_USER} ALL=(ALL) NOPASSWD: ALL" > "$sudoers_tmp"
if visudo -c -f "$sudoers_tmp" >/dev/null 2>&1; then
install -m 0440 -o root -g root "$sudoers_tmp" "$sudoers_file"
success "sudo NOPASSWD habilitado para $TNODE_USER (vía $sudoers_file)"
else
warn "sudoers drop-in falló validación visudo — saltando grant"
fi
rm -f "$sudoers_tmp"
}
# Progress bar for long-running commands (spinner + bar + elapsed time)
# Usage: run_with_progress "Label" [--estimate SECS] command args...
run_with_progress() {
local label="$1"; shift
local estimate=45 # default estimated duration in seconds
if [[ "${1:-}" == "--estimate" ]]; then
estimate="$2"; shift 2
fi
local logfile
logfile="$(mktemp)"
local start_time=$SECONDS
local spinner_frames
spinner_frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
local bar_width=20
# Run command in background, capture output
"$@" >"$logfile" 2>&1 &
local pid=$!
# Animated progress bar
local i=0
while kill -0 "$pid" 2>/dev/null; do
local elapsed=$(( SECONDS - start_time ))
# Progress: ramp to 90% based on estimate, hold there until done
local pct=$(( elapsed * 90 / (estimate > 0 ? estimate : 1) ))
if [[ "$pct" -gt 90 ]]; then pct=90; fi
local filled=$(( pct * bar_width / 100 ))
local empty=$(( bar_width - filled ))
local s_idx=$(( i % ${#spinner_frames[@]} ))
local spinner="${spinner_frames[$s_idx]}"
# Build bar string
local bar=""
local j=0
while [[ "$j" -lt "$filled" ]]; do bar="${bar}█"; j=$(( j + 1 )); done
j=0
while [[ "$j" -lt "$empty" ]]; do bar="${bar}░"; j=$(( j + 1 )); done
printf "\r ${MUTED}%s %s [%s] %d%% (%ds)${NC}" "$spinner" "$label" "$bar" "$pct" "$elapsed"
i=$(( i + 1 ))
sleep 0.3
done
# Get exit code
wait "$pid"
local rc=$?
local elapsed=$(( SECONDS - start_time ))
# Final state: full bar or error
if [[ "$rc" == "0" ]]; then
local bar=""
local j=0
while [[ "$j" -lt "$bar_width" ]]; do bar="${bar}█"; j=$(( j + 1 )); done
printf "\r ${GREEN}✓${NC} %s ${MUTED}[%s] 100%% (%ds)${NC}\n" "$label" "$bar" "$elapsed"
else
printf "\r ${RED}✗${NC} %s ${MUTED}(%ds)${NC}\n" "$label" "$elapsed"
fail "Falló: $label"
tail -5 "$logfile" | while IFS= read -r line; do
echo -e " ${MUTED}${line}${NC}"
done
fi
if [[ "$VERBOSE" == "1" ]] && [[ "$rc" == "0" ]]; then
tail -5 "$logfile" | while IFS= read -r line; do
echo -e " ${MUTED}${line}${NC}"
done
fi
rm -f "$logfile"
return $rc
}
detect_os() {
OS="$(uname -s)"
ARCH="$(uname -m)"
case "$ARCH" in
aarch64) ARCH="arm64" ;;
esac
}
confirm() {
if [[ "$YES" == "1" ]]; then return 0; fi
local prompt="$1"
echo -en " ${prompt} [Y/n] "
read -r answer < /dev/tty 2>/dev/null || answer="y"
case "${answer:-y}" in
[Yy]*) return 0 ;;
*) return 1 ;;
esac
}
# Ensures Python's `websockets` library is at version >= 13.
# Background: tnode_telemetry.py uses the new server-handler signature
# `async def handler(ws):` which only works with websockets >= 11. Older
# versions (e.g. Ubuntu 22.04's `python3-websockets` apt package ships
# 9.1) silently RESET incoming TCP connections without logging anything,
# because the lib invokes the handler with an extra `path` arg and the
# call fails before the WS upgrade completes. Symptom from the client
# side: pair-QR scan times out / app shows "no se pudo conectar" with no
# server-side log entry. Fix: pip-install >= 13 over the apt package.
ensure_websockets_modern() {
local current
current="$(/usr/bin/env python3 -c 'import websockets; print(websockets.__version__)' 2>/dev/null || echo 'none')"
if [[ "$current" != "none" ]]; then
local major
major="$(echo "$current" | cut -d. -f1)"
if [[ "$major" =~ ^[0-9]+$ ]] && [[ "$major" -ge 13 ]]; then
success "websockets ${current} OK (>=13)"
return 0
fi
info "websockets ${current} es muy viejo — actualizando a >=13"
else
info "websockets no está instalado — instalando >=13"
fi
# 1) Ensure pip is available — install directly (no run_with_progress) so
# apt/dnf/yum failures surface their real exit code instead of being
# hidden behind a spinner that always reports "OK".
if ! /usr/bin/env python3 -m pip --version >/dev/null 2>&1; then
info "Instalando python3-pip (necesario para upgrade de websockets)..."
local pip_install_log
pip_install_log="$(mktemp)"
local pip_install_rc=0
if command_exists apt-get; then
DEBIAN_FRONTEND=noninteractive apt-get install -y python3-pip >"$pip_install_log" 2>&1 || pip_install_rc=$?
elif command_exists dnf; then
dnf install -y python3-pip >"$pip_install_log" 2>&1 || pip_install_rc=$?
elif command_exists yum; then
yum install -y python3-pip >"$pip_install_log" 2>&1 || pip_install_rc=$?
elif [[ "$(uname)" == "Darwin" ]]; then
: # macOS python3 trae pip incluido
else
rm -f "$pip_install_log"
die "No hay package manager (apt/dnf/yum) — instala python3-pip manualmente y reintenta"
fi
if [[ "$pip_install_rc" -ne 0 ]]; then
warn "package install de python3-pip falló (exit ${pip_install_rc}). Últimas líneas:"
tail -10 "$pip_install_log" >&2 || true
rm -f "$pip_install_log"
die "No se pudo instalar python3-pip — websockets no se puede actualizar y el sidecar fallará"
fi
rm -f "$pip_install_log"
# Validate: check the install actually produced a working pip. apt
# sometimes reports success without doing anything (stale dpkg lock,
# bad mirror state) — catch that here instead of letting it cascade.
if ! /usr/bin/env python3 -m pip --version >/dev/null 2>&1; then
die "python3-pip se instaló sin error, pero 'python3 -m pip' sigue sin responder. Revisa el state de apt/dpkg"
fi
success "python3-pip OK"
fi
# 2) pip-install websockets. Try plain --upgrade first; fall back to
# --break-system-packages for PEP 668 (Ubuntu 24.04+, Debian 12+) where
# system Python is marked "externally managed".
info "Actualizando websockets vía pip..."
local ws_install_log
ws_install_log="$(mktemp)"
local ws_install_rc=0
/usr/bin/env python3 -m pip install --upgrade "websockets>=13,<14" >"$ws_install_log" 2>&1 || ws_install_rc=$?
if [[ "$ws_install_rc" -ne 0 ]] && grep -q "externally-managed-environment" "$ws_install_log"; then
info "Reintentando con --break-system-packages (PEP 668)"
ws_install_rc=0
/usr/bin/env python3 -m pip install --upgrade --break-system-packages "websockets>=13,<14" >"$ws_install_log" 2>&1 || ws_install_rc=$?
fi
if [[ "$ws_install_rc" -ne 0 ]]; then
warn "pip install websockets falló (exit ${ws_install_rc}). Últimas líneas:"
tail -10 "$ws_install_log" >&2 || true
rm -f "$ws_install_log"
die "No se pudo actualizar websockets a >=13 — el sidecar tnode-telemetry resetará todas las conexiones del cliente"
fi
rm -f "$ws_install_log"
# 3) Validate the install actually landed. pip can report success while
# leaving an old shadow copy first in sys.path (e.g. apt's
# python3-websockets in /usr/lib/python3/dist-packages takes precedence
# over /usr/local/lib/... on some distros). Confirm what tnode user sees.
local final_version
final_version="$(/usr/bin/env python3 -c 'import websockets; print(websockets.__version__)' 2>/dev/null || echo 'none')"
if [[ "$final_version" == "none" ]]; then
die "Tras pip install, 'import websockets' falla. Revisa sys.path con: python3 -c 'import sys; print(sys.path)'"
fi
local final_major
final_major="$(echo "$final_version" | cut -d. -f1)"
if [[ ! "$final_major" =~ ^[0-9]+$ ]] || [[ "$final_major" -lt 13 ]]; then
die "Tras pip install, websockets sigue en ${final_version}. Probablemente apt's python3-websockets está shadowing el pip install — desinstala con: apt remove -y python3-websockets"
fi
success "websockets actualizado a ${final_version}"
}
# Ensures Python `psutil` is importable. The telemetry sidecar uses psutil
# to collect CPU/RAM/disk metrics for the `health` stream — without it,
# the sidecar logs a warning and silently disables the stream, so the
# mobile dashboard's Salud widget shows no data (Mini incident 2026-05-07).
# Linux: apt/dnf/yum already install python3-psutil during phase_helpers,
# so this is mostly a safety net. macOS: Apple's Python ships without it,
# pip-install with PEP 668 fallback (matches ensure_websockets_modern).
ensure_psutil_installed() {
if /usr/bin/env python3 -c 'import psutil' >/dev/null 2>&1; then
local v
v="$(/usr/bin/env python3 -c 'import psutil; print(psutil.__version__)' 2>/dev/null || echo 'unknown')"
success "psutil ${v} OK"
return 0
fi
info "psutil no está instalado — el stream health del sidecar lo necesita"
if ! /usr/bin/env python3 -m pip --version >/dev/null 2>&1; then
warn "python3-pip no disponible — saltando install de psutil (health stream quedará deshabilitado)"
return 1
fi
local install_log
install_log="$(mktemp)"
local rc=0
/usr/bin/env python3 -m pip install psutil >"$install_log" 2>&1 || rc=$?
if [[ "$rc" -ne 0 ]] && grep -q "externally-managed-environment" "$install_log"; then
info "Reintentando con --break-system-packages (PEP 668)"
rc=0
/usr/bin/env python3 -m pip install --break-system-packages psutil >"$install_log" 2>&1 || rc=$?
fi
if [[ "$rc" -ne 0 ]]; then
warn "pip install psutil falló (exit ${rc}). Últimas líneas:"
tail -10 "$install_log" >&2 || true
rm -f "$install_log"
warn "Health stream del sidecar quedará deshabilitado en este nodo"
return 1
fi
rm -f "$install_log"
local final_version
final_version="$(/usr/bin/env python3 -c 'import psutil; print(psutil.__version__)' 2>/dev/null || echo 'none')"
if [[ "$final_version" == "none" ]]; then
warn "Tras pip install, 'import psutil' falla. Health stream deshabilitado"
return 1
fi
success "psutil instalado (${final_version})"
}
# Resolve Homebrew binary
resolve_brew() {
local brew=""
brew="$(command -v brew 2>/dev/null || true)"
if [[ -n "$brew" ]]; then echo "$brew"; return 0; fi
if [[ -x "/opt/homebrew/bin/brew" ]]; then echo "/opt/homebrew/bin/brew"; return 0; fi
if [[ -x "/usr/local/bin/brew" ]]; then echo "/usr/local/bin/brew"; return 0; fi
return 1
}
ensure_brew_on_path() {
if ! command_exists brew; then
local brew_bin
brew_bin="$(resolve_brew || true)"
if [[ -n "$brew_bin" ]]; then
eval "$("$brew_bin" shellenv)"
fi
fi
}
# Add ~/bin to PATH in shell rc if not present
ensure_path_in_rc() {
local rc_file=""
case "$OS" in
Darwin) rc_file="$HOME/.zshrc" ;;
Linux) rc_file="$HOME/.bashrc" ;;
esac
if [[ -z "$rc_file" ]]; then return; fi
local path_line='export PATH="$HOME/bin:$PATH"'
if [[ -f "$rc_file" ]] && grep -qF 'HOME/bin' "$rc_file"; then
return 0
fi
echo "" >> "$rc_file"
echo "# Added by TNode setup" >> "$rc_file"
echo "$path_line" >> "$rc_file"
success "Added ~/bin to PATH in $rc_file"
}
# Same as ensure_path_in_rc but for tnode user's home
ensure_path_in_rc_for_tnode() {
local rc_file="$TNODE_HOME/.bashrc"
if [[ "$OS" == "Darwin" ]]; then rc_file="$TNODE_HOME/.zshrc"; fi
if [[ -z "$rc_file" ]]; then return; fi
local path_line='export PATH="$HOME/bin:$PATH"'
if [[ -f "$rc_file" ]] && grep -qF 'HOME/bin' "$rc_file"; then
return 0
fi
echo "" >> "$rc_file"
echo "# Added by TNode setup" >> "$rc_file"
echo "$path_line" >> "$rc_file"
chown "$TNODE_USER":"$TNODE_USER" "$rc_file" 2>/dev/null || true
success "Added ~/bin to PATH in $rc_file"
}
# ─────────────────────────────────────────────
# GPU detection → smart model default
# ─────────────────────────────────────────────
detect_gpu() {
# Returns 0 (true) if GPU with sufficient VRAM is available
case "$OS" in
Darwin)
# macOS Apple Silicon always has unified memory → GPU-capable
if [[ "$ARCH" == "arm64" ]]; then
return 0
fi
# Intel Mac: check for discrete GPU
if system_profiler SPDisplaysDataType 2>/dev/null | grep -qi "Metal\|Radeon\|NVIDIA"; then
return 0
fi
return 1
;;
Linux)
# Check NVIDIA GPU
if command_exists nvidia-smi; then
local vram_mb
vram_mb="$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | head -1 || echo "0")"
if [[ "${vram_mb:-0}" -ge 4000 ]]; then
return 0
fi
fi
# Check for ROCm (AMD)
if command_exists rocm-smi; then
return 0
fi
return 1
;;
esac
return 1
}
select_default_model() {
# If user explicitly set --model, respect it
if [[ "$MODEL_EXPLICIT" == "1" ]]; then
return 0
fi
if [[ "$USE_CLOUD" == "1" ]]; then
MODEL="$CLOUD_MODEL"
return 0
fi
if [[ "$USE_API" == "1" ]]; then
# API mode: use provider's default model
MODEL="$(api_provider_default_model "$API_PROVIDER")"
info "Modo API ($API_PROVIDER) → modelo: $MODEL"
return 0
fi
if detect_gpu; then
MODEL="qwen3.5"
info "GPU detectada → modelo grande: $MODEL (~6.6 GB)"
else
MODEL="qwen2.5:1.5b"
info "Sin GPU dedicada → modelo ligero: $MODEL (~1.0 GB, optimizado para CPU)"
fi
}
# ─────────────────────────────────────────────
# Argument parsing
# ─────────────────────────────────────────────
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--yes|-y) YES=1 ;;
--no-ollama) NO_OLLAMA=1 ;;
--no-tunnel) NO_TUNNEL=1 ;;
--with-tailscale) WITH_TAILSCALE=1 ;;
--no-tailscale) NO_TAILSCALE=1 ;; # legacy compat (Tailscale off by default now)
--tunnel-token) TUNNEL_TOKEN="${2:?--tunnel-token requires a value}"; shift ;;
--no-qr) NO_QR=1 ;;
--model) MODEL="${2:?--model requires a value}"; MODEL_EXPLICIT=1; shift ;;
--cloud) USE_CLOUD=1; MODEL="$CLOUD_MODEL" ;;
--api)
USE_API=1; NO_OLLAMA=1; API_PROVIDER="openrouter"
# If next arg is not a flag, treat it as API key
if [[ -n "${2:-}" ]] && [[ "${2}" != --* ]]; then
API_KEY="$2"
shift
fi
;;
--verbose) VERBOSE=1 ;;
--update-only) UPDATE_ONLY=1 ;;
--component)
COMPONENT="${2:?--component requires a value (e.g. tnode-chat-sync)}"
UPDATE_ONLY=1
shift
;;
--no-smoke-test) NO_SMOKE_TEST=1 ;;
--uninstall) UNINSTALL=1 ;;
--purge-binaries) PURGE_BINARIES=1 ;;
--version) echo "tnode-setup v${TNODE_SETUP_VERSION}"; exit 0 ;;
-h|--help) print_usage; exit 0 ;;
*) warn "Unknown flag: $1" ;;
esac
shift
done
# Validate --component against the supported list
if [[ -n "$COMPONENT" ]]; then
local found=0
local c
for c in "${SUPPORTED_COMPONENTS[@]}"; do
if [[ "$c" == "$COMPONENT" ]]; then
found=1
break
fi
done
if [[ "$found" == "0" ]]; then
local list="${SUPPORTED_COMPONENTS[*]}"
die "Unknown --component '$COMPONENT'. Supported: ${list// /, }"
fi
fi
}
print_usage() {
cat <<EOF
TNode Setup v${TNODE_SETUP_VERSION}
Usage: bash tnode-setup.sh [OPTIONS]
curl -fsSL https://install.tbrain.app | bash
Options:
--yes, -y Non-interactive mode
--no-ollama Skip Ollama installation
--no-tunnel Skip Cloudflare Tunnel (use Tailscale or LAN instead)
--with-tailscale Install Tailscale alongside tunnel (or as alternative)
--tunnel-token T Pre-provisioned Cloudflare tunnel token
--no-qr Skip QR display at the end
--model <name> LLM model (default: auto-detect GPU/CPU)
--cloud Use cloud model (${CLOUD_MODEL})
--api [key] Use cloud API (OpenRouter). Key opcional, incluida por defecto.
--verbose Enable debug output
--update-only Refresh scripts/binaries without rotating secrets.
Aborts if required state (tunnel.json, gateway token)
is missing instead of regenerating it.
--component <name> Only install/refresh the named component (implies
--update-only). Supported: openclaw-gateway,
tnode-chat-sync, tnode-config-sync, tnode-telemetry,
pair-watch, cloudflared.
--no-smoke-test Skip post-update verify_<X>.py smoke test.
--uninstall Local cleanup only: stop+disable systemd/launchd units,
remove unit files, delete ~/.openclaw. Does NOT touch
server-side state (Firestore, Cloudflare tunnel) — for
that, delete the node from the TNode mobile app first
(which invokes the deleteAgent Cloud Function).
--purge-binaries With --uninstall, also delete /usr/local/bin/cloudflared
and /usr/bin/openclaw. Off by default (other apps may
use them).
--version Print version and exit
-h, --help Show this help
Model defaults:
GPU detected → qwen3.5 (~6.6 GB VRAM)
CPU only → qwen2.5:1.5b (~1.0 GB, fast on CPU)
--api → claude-3.5-haiku via OpenRouter (incluido)
Connectivity:
Default → Cloudflare Tunnel (wss://, sin VPN requerido)
--no-tunnel → Requiere Tailscale (--with-tailscale) o acceso LAN
--with-tailscale → Tailscale como fallback adicional
Examples:
# VPS sin GPU — API cloud + tunnel (default):
bash tnode-setup.sh --api
# VPS con key propia:
bash tnode-setup.sh --api sk-or-v1-xxx
# Sin tunnel (dev local con Tailscale):
bash tnode-setup.sh --no-tunnel --with-tailscale
# Mac Mini con Apple Silicon — LLM local:
bash tnode-setup.sh
# Raspberry Pi — solo gateway, LLM en otro nodo:
bash tnode-setup.sh --no-ollama --yes
# Refrescar scripts/binarios sin rotar secretos:
curl -fsSL https://install.tbrain.app | bash -s -- --update-only --yes
# Actualizar solo un daemon (incluye verify smoke-test):
curl -fsSL https://install.tbrain.app | bash -s -- --component tnode-chat-sync --yes
EOF
}
print_banner() {
echo ""
echo -e " ${BOLD}╭─────────────────────────────────────╮${NC}"
echo -e " ${BOLD}│ TNode Setup v${TNODE_SETUP_VERSION} │${NC}"
echo -e " ${BOLD}│ Configura tu nodo para TNode │${NC}"
echo -e " ${BOLD}╰─────────────────────────────────────╯${NC}"
}
# ═════════════════════════════════════════════
# PHASE 1: Validations
# ═════════════════════════════════════════════
phase_validate() {
phase "1/7" "Validaciones"
detect_os
# Supported platform?
case "${OS}/${ARCH}" in
Darwin/arm64) success "macOS arm64 (Apple Silicon)" ;;
Linux/arm64) success "Linux arm64 (Raspberry Pi / ARM server)" ;;
Linux/x86_64) success "Linux x86_64" ;;
Darwin/x86_64) success "macOS x86_64 (Intel)" ;;
*) die "Plataforma no soportada: ${OS}/${ARCH}" ;;
esac
# Create tnode user (Linux) or use current user (macOS / non-root)
setup_tnode_user
# Internet check
if curl -fsSL --max-time 5 https://registry.npmjs.org/ >/dev/null 2>&1; then
success "Conexión a internet"
else
die "Sin conexión a internet"
fi
# Python 3
if command_exists python3; then
local pyver
pyver="$(python3 --version 2>&1 | awk '{print $2}')"
success "Python $pyver"
else
warn "Python 3 no encontrado — pair-watch requiere Python 3.9+"
if [[ "$OS" == "Darwin" ]]; then
info "Se instalará con Xcode command line tools"
fi
fi
# Memory pre-check. OpenClaw's npm install needs ~1 GB; on Ubuntu 512 MB
# droplets it gets killed by the OOM killer mid-install (seen on
# do $5/mo droplets). Fail fast with a useful message.
if [[ "$OS" == "Linux" ]] && [[ -r /proc/meminfo ]]; then
local mem_kb mem_mb
mem_kb="$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)"
mem_mb=$((mem_kb / 1024))
if [[ "$mem_mb" -gt 0 ]] && [[ "$mem_mb" -lt 512 ]]; then
die "RAM insuficiente: ${mem_mb} MB. OpenClaw requiere >=1024 MB (npm install será killed por OOM). Upgrade a un droplet de al menos 1 GB."
elif [[ "$mem_mb" -gt 0 ]] && [[ "$mem_mb" -lt 1024 ]]; then
warn "RAM limitada: ${mem_mb} MB. Recomendado >=1024 MB — npm install de OpenClaw puede ser killed por OOM."
info "Si querés intentarlo igual, activá swap antes:"
info " fallocate -l 1G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile"
else
[[ "$mem_mb" -gt 0 ]] && success "RAM ${mem_mb} MB"
fi
fi
# Auto-detect: if no GPU and user didn't explicitly choose local/cloud, use API mode
if [[ "$USE_API" == "0" ]] && [[ "$USE_CLOUD" == "0" ]] && [[ "$MODEL_EXPLICIT" == "0" ]]; then
if ! detect_gpu; then
info "Sin GPU detectada → activando modo API automáticamente"
USE_API=1
NO_OLLAMA=1
API_PROVIDER="openrouter"
fi
fi
# Auto-detect model based on GPU
select_default_model
}
# ═════════════════════════════════════════════
# PHASE 2: Ollama
# ═════════════════════════════════════════════
phase_ollama() {
phase "2/7" "Ollama"
if [[ "$USE_API" == "1" ]]; then
info "Modo API ($API_PROVIDER) — no se necesita Ollama local"
success "LLM via API: $API_PROVIDER"
return 0
fi
if [[ "$NO_OLLAMA" == "1" ]]; then
info "Saltando Ollama (--no-ollama)"
return 0
fi
# Install Ollama if not present
if command_exists ollama; then
local ollama_ver
ollama_ver="$(ollama --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "?")"
success "Ollama v${ollama_ver} ya instalado"
else
info "Instalando Ollama..."
case "$OS" in
Darwin)
local tmp_dir
tmp_dir="$(mktemp -d)"
curl -fSL --progress-bar -o "$tmp_dir/Ollama-darwin.zip" \
"https://ollama.com/download/Ollama-darwin.zip"
# Stop existing if running
pkill -x Ollama 2>/dev/null || true
sleep 1
# Remove old version
[[ -d "/Applications/Ollama.app" ]] && rm -rf "/Applications/Ollama.app"
# Install
unzip -q "$tmp_dir/Ollama-darwin.zip" -d "$tmp_dir"
mv "$tmp_dir/Ollama.app" "/Applications/"
# Symlink CLI
if [[ ! -L "/usr/local/bin/ollama" ]] || \
[[ "$(readlink "/usr/local/bin/ollama" 2>/dev/null)" != "/Applications/Ollama.app/Contents/Resources/ollama" ]]; then
mkdir -p "/usr/local/bin" 2>/dev/null || sudo mkdir -p "/usr/local/bin"
ln -sf "/Applications/Ollama.app/Contents/Resources/ollama" "/usr/local/bin/ollama" 2>/dev/null || \
sudo ln -sf "/Applications/Ollama.app/Contents/Resources/ollama" "/usr/local/bin/ollama"
fi
# Start Ollama
open -a Ollama --args hidden
sleep 3 # Give it time to start the API server
rm -rf "$tmp_dir"
success "Ollama instalado en /Applications"
;;
Linux)
info "Ejecutando installer oficial de Ollama..."
# Download to temp file first — Ollama's installer uses set -eu
# and EXIT traps that kill parent scripts when piped directly
local ollama_tmp
ollama_tmp="$(mktemp)"
curl -fsSL https://ollama.com/install.sh -o "$ollama_tmp"
bash "$ollama_tmp" || true
rm -f "$ollama_tmp"
# Ensure systemd service is started
if command_exists systemctl; then
systemctl daemon-reload 2>/dev/null || true
systemctl enable ollama 2>/dev/null || true
systemctl start ollama 2>/dev/null || true
fi
# Refresh PATH — Ollama installs to /usr/local/bin
hash -r 2>/dev/null || true
success "Ollama instalado"
;;
esac
fi
# Verify Ollama API is running (wait up to 30s)
info "Esperando que Ollama API esté lista..."
local retries=0
while ! curl -sf http://localhost:11434/api/tags >/dev/null 2>&1; do
retries=$((retries + 1))
if [[ $retries -ge 15 ]]; then
warn "Ollama no responde en localhost:11434"
if [[ "$OS" == "Linux" ]] && command_exists systemctl; then
info "Intentando reiniciar servicio..."
systemctl restart ollama 2>/dev/null || true
sleep 3
if curl -sf http://localhost:11434/api/tags >/dev/null 2>&1; then
success "Ollama API lista (tras reinicio)"
break
fi
fi
warn "Ollama no responde — intenta iniciarlo manualmente"
return 0
fi
sleep 2
done
if [[ $retries -lt 15 ]]; then
success "Ollama API lista"
fi
# Pull model (only for local models, not cloud)
if [[ "$USE_CLOUD" == "1" ]]; then
info "Modo cloud: modelo $MODEL se descarga on-demand, no requiere pull"
else
if ollama list 2>/dev/null | grep -q "${MODEL%%:*}"; then
success "Modelo $MODEL ya disponible"
else
info "Descargando modelo $MODEL (esto puede tardar varios minutos)..."
run_with_progress "Descargando $MODEL" --estimate 120 ollama pull "$MODEL"
success "Modelo $MODEL listo"
fi
fi
}
# ═════════════════════════════════════════════