jishushell 0.4.2-beta2 → 0.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/Dockerfile.openclaw-slim +58 -0
  2. package/INSTALL-NOTICE +7 -1
  3. package/dist/auth.js +3 -3
  4. package/dist/auth.js.map +1 -1
  5. package/dist/cli.js +517 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config.d.ts +21 -4
  8. package/dist/config.js +88 -54
  9. package/dist/config.js.map +1 -1
  10. package/dist/control.js +5 -5
  11. package/dist/control.js.map +1 -1
  12. package/dist/doctor.js +47 -14
  13. package/dist/doctor.js.map +1 -1
  14. package/dist/install.d.ts +1 -1
  15. package/dist/install.js +15 -29
  16. package/dist/install.js.map +1 -1
  17. package/dist/routes/backup.d.ts +2 -0
  18. package/dist/routes/backup.js +370 -0
  19. package/dist/routes/backup.js.map +1 -0
  20. package/dist/routes/instances.d.ts +1 -0
  21. package/dist/routes/instances.js +51 -11
  22. package/dist/routes/instances.js.map +1 -1
  23. package/dist/routes/setup.js +3 -5
  24. package/dist/routes/setup.js.map +1 -1
  25. package/dist/server.js +29 -1
  26. package/dist/server.js.map +1 -1
  27. package/dist/services/backup-manager.d.ts +253 -0
  28. package/dist/services/backup-manager.js +2014 -0
  29. package/dist/services/backup-manager.js.map +1 -0
  30. package/dist/services/backup-verify.d.ts +26 -0
  31. package/dist/services/backup-verify.js +240 -0
  32. package/dist/services/backup-verify.js.map +1 -0
  33. package/dist/services/instance-manager.d.ts +24 -4
  34. package/dist/services/instance-manager.js +218 -49
  35. package/dist/services/instance-manager.js.map +1 -1
  36. package/dist/services/nomad-manager.js +72 -131
  37. package/dist/services/nomad-manager.js.map +1 -1
  38. package/dist/services/process-manager.js +4 -3
  39. package/dist/services/process-manager.js.map +1 -1
  40. package/dist/services/setup-manager.d.ts +4 -2
  41. package/dist/services/setup-manager.js +268 -129
  42. package/dist/services/setup-manager.js.map +1 -1
  43. package/dist/utils/fs.d.ts +85 -0
  44. package/dist/utils/fs.js +111 -0
  45. package/dist/utils/fs.js.map +1 -0
  46. package/dist/utils/safe-json.d.ts +2 -0
  47. package/dist/utils/safe-json.js +22 -16
  48. package/dist/utils/safe-json.js.map +1 -1
  49. package/install/jishu-install-china.sh +3092 -0
  50. package/install/jishu-install.sh +310 -108
  51. package/install/jishu-uninstall.sh +276 -391
  52. package/install/post-install.sh +9 -0
  53. package/openclaw-entry.sh +15 -0
  54. package/package.json +4 -1
  55. package/public/assets/Dashboard-DhsrzJ4F.js +1 -0
  56. package/public/assets/{InitPassword-CslWYy8G.js → InitPassword-BjubiVdd.js} +1 -1
  57. package/public/assets/InstanceDetail-DMcywsof.js +17 -0
  58. package/public/assets/{Login-d45wtgVA.js → Login-CUoEZOWR.js} +1 -1
  59. package/public/assets/NewInstance-Bk0G4EiJ.js +1 -0
  60. package/public/assets/Settings-D5tHL_h5.js +1 -0
  61. package/public/assets/Setup-4t6E3Rut.js +1 -0
  62. package/public/assets/index-BJ47MWpF.css +1 -0
  63. package/public/assets/index-DbX85irc.js +16 -0
  64. package/public/assets/{usePolling-CqQ8hrNc.js → usePolling-CK0DfI4h.js} +1 -1
  65. package/public/assets/{vendor-i18n-Bvxxh8Di.js → vendor-i18n-CfW0RvgE.js} +1 -1
  66. package/public/assets/vendor-react-B1-3Yrt-.js +59 -0
  67. package/public/index.html +4 -4
  68. package/public/assets/Dashboard-Dxsq690N.js +0 -1
  69. package/public/assets/InstanceDetail-DmEkMj-t.js +0 -14
  70. package/public/assets/NewInstance-Czp5-AJe.js +0 -1
  71. package/public/assets/Settings-BKMGck05.js +0 -1
  72. package/public/assets/Setup-D3rfLWjZ.js +0 -1
  73. package/public/assets/index-77Ug7feY.css +0 -1
  74. package/public/assets/index-DkDnIohs.js +0 -16
  75. package/public/assets/vendor-react-DONn7uBV.js +0 -59
@@ -0,0 +1,3092 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # ═══════════════════════════════════════════════════════════════════════════════
5
+ # JishuShell Installer
6
+ #
7
+ # Self-contained installer for all JishuShell dependencies:
8
+ # Node.js (via nvm), Docker, Nomad, OpenClaw Docker image
9
+ #
10
+ # Can also be sourced by other scripts (post-install.sh, jishu-uninstall.sh)
11
+ # to reuse shared functions — main() only runs when executed directly.
12
+ #
13
+ # Usage:
14
+ # bash jishu-install.sh [options] — run the full installer
15
+ # source jishu-install.sh — import functions only
16
+ # ═══════════════════════════════════════════════════════════════════════════════
17
+
18
+ # Guard against double-sourcing
19
+ if [[ -n "${_JISHU_INSTALL_LOADED:-}" ]]; then
20
+ return 0 2>/dev/null || true
21
+ fi
22
+ _JISHU_INSTALL_LOADED=1
23
+
24
+ # ═══════════════════════════════════════════════════════════════════════════════
25
+ # Temporary File Management (trap cleanup)
26
+ # ═══════════════════════════════════════════════════════════════════════════════
27
+ TMPFILES=()
28
+ _SUDO_KEEPALIVE_PID=""
29
+
30
+ cleanup_tmpfiles() {
31
+ local f
32
+ for f in "${TMPFILES[@]:-}"; do
33
+ rm -rf "$f" 2>/dev/null || true
34
+ done
35
+ # Kill sudo keepalive background process if running
36
+ if [[ -n "${_SUDO_KEEPALIVE_PID:-}" ]]; then
37
+ kill "$_SUDO_KEEPALIVE_PID" 2>/dev/null || true
38
+ fi
39
+ }
40
+ trap cleanup_tmpfiles EXIT
41
+
42
+ mktempfile() {
43
+ local f
44
+ f="$(mktemp)"
45
+ TMPFILES+=("$f")
46
+ echo "$f"
47
+ }
48
+
49
+ # ═══════════════════════════════════════════════════════════════════════════════
50
+ # Download Tool Detection
51
+ # ═══════════════════════════════════════════════════════════════════════════════
52
+ DOWNLOADER=""
53
+ detect_downloader() {
54
+ if command -v curl &> /dev/null; then
55
+ DOWNLOADER="curl"
56
+ return 0
57
+ fi
58
+ if command -v wget &> /dev/null; then
59
+ DOWNLOADER="wget"
60
+ return 0
61
+ fi
62
+ ui_error "Missing downloader (curl or wget required)"
63
+ exit 1
64
+ }
65
+
66
+ download_file() {
67
+ local url="$1"
68
+ local output="$2"
69
+ if [[ -z "$DOWNLOADER" ]]; then
70
+ detect_downloader
71
+ fi
72
+ if [[ "$DOWNLOADER" == "curl" ]]; then
73
+ curl -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 --retry-connrefused -o "$output" "$url"
74
+ return
75
+ fi
76
+ wget -q --https-only --secure-protocol=TLSv1_2 --tries=3 --timeout=20 -O "$output" "$url"
77
+ }
78
+
79
+ # retry_net <description> <max_attempts> <cmd> [args...]
80
+ # Runs cmd up to max_attempts times; waits 5s, 10s between retries.
81
+ # Network-related exit codes (6,7,28,35,56 for curl; DNS/timeout) are all retried.
82
+ retry_net() {
83
+ local desc="$1"
84
+ local max="${2:-3}"
85
+ shift 2
86
+ local attempt=1
87
+ local delay=5
88
+ while true; do
89
+ if "$@"; then
90
+ return 0
91
+ fi
92
+ local rc=$?
93
+ if [[ $attempt -ge $max ]]; then
94
+ ui_error "${desc} failed after ${max} attempts (last exit code: ${rc})"
95
+ return $rc
96
+ fi
97
+ ui_warn "${desc} failed (attempt ${attempt}/${max}, exit ${rc}) — retrying in ${delay}s..."
98
+ sleep "$delay"
99
+ attempt=$(( attempt + 1 ))
100
+ delay=$(( delay * 2 ))
101
+ done
102
+ }
103
+
104
+ # ═══════════════════════════════════════════════════════════════════════════════
105
+ # Non-interactive Shell Detection
106
+ # ═══════════════════════════════════════════════════════════════════════════════
107
+ is_non_interactive_shell() {
108
+ if [[ "${NO_PROMPT:-0}" == "1" ]]; then
109
+ return 0
110
+ fi
111
+ if [[ ! -t 0 || ! -t 1 ]]; then
112
+ return 0
113
+ fi
114
+ return 1
115
+ }
116
+
117
+ is_promptable() {
118
+ if [[ "${NO_PROMPT:-0}" == "1" ]]; then
119
+ return 1
120
+ fi
121
+ if [[ -r /dev/tty && -w /dev/tty ]]; then
122
+ return 0
123
+ fi
124
+ return 1
125
+ }
126
+
127
+ # ═══════════════════════════════════════════════════════════════════════════════
128
+ # Holiday Taglines (fun Easter eggs)
129
+ # ═══════════════════════════════════════════════════════════════════════════════
130
+ HOLIDAY_NEW_YEAR="New year, new config—same old EADDRINUSE, but this time we resolve it like grown-ups."
131
+ HOLIDAY_CHRISTMAS="Ho ho ho—Santa's little helper is here to ship joy, roll back chaos, and stash the keys safely."
132
+ HOLIDAY_HALLOWEEN="Spooky season: beware haunted dependencies, cursed caches, and the ghost of node_modules past."
133
+ HOLIDAY_THANKSGIVING="Grateful for stable ports, working DNS, and a bot that reads the logs so nobody has to."
134
+
135
+ append_holiday_taglines() {
136
+ local month_day
137
+ month_day="$(date -u +%m-%d 2>/dev/null || date +%m-%d)"
138
+ case "$month_day" in
139
+ "01-01") TAGLINE="${HOLIDAY_NEW_YEAR}" ;;
140
+ "12-25") TAGLINE="${HOLIDAY_CHRISTMAS}" ;;
141
+ "10-31") TAGLINE="${HOLIDAY_HALLOWEEN}" ;;
142
+ "11-27"|"11-28") TAGLINE="${HOLIDAY_THANKSGIVING}" ;;
143
+ esac
144
+ }
145
+
146
+ pick_tagline() {
147
+ if [[ -n "${JISHU_TAGLINE:-}" ]]; then
148
+ TAGLINE="${JISHU_TAGLINE}"
149
+ return
150
+ fi
151
+ append_holiday_taglines
152
+ if [[ -z "${TAGLINE:-}" ]]; then
153
+ TAGLINE=" All your agents, one JishuShell."
154
+ fi
155
+ }
156
+
157
+ # Script directory (BASH_SOURCE[0] is unset when piped via curl|bash, fall back to $0)
158
+ JISHU_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd 2>/dev/null || pwd)"
159
+
160
+ # ═══════════════════════════════════════════════════════════════════════════════
161
+ # ──── BEGIN VERSIONS ────
162
+ NODE_VERSION="${JISHU_NODE_VERSION:-22}"
163
+ NVM_VERSION="${JISHU_NVM_VERSION:-0.40.4}"
164
+ NOMAD_VERSION="${JISHU_NOMAD_VERSION:-1.11.3}"
165
+ JISHUSHELL_PORT="${JISHUSHELL_PORT:-8090}"
166
+
167
+ # ──── NPM Registry Configuration ────
168
+ # Pass --registry <url> to override the npm registry for all installs.
169
+ NPM_REGISTRY="${NPM_REGISTRY:-}"
170
+ # Resolve the real (non-root) user when running via sudo.
171
+ # REAL_USER / REAL_HOME are used wherever paths must belong to the target user.
172
+ if [[ $EUID -eq 0 && -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then
173
+ REAL_USER="${SUDO_USER}"
174
+ REAL_HOME="$(getent passwd "${SUDO_USER}" | cut -d: -f6 2>/dev/null || echo "")"
175
+ elif [[ $EUID -eq 0 ]]; then
176
+ # Running as root without sudo — try to detect the first non-root UID-1000 user
177
+ # (common default user on Raspberry Pi, Ubuntu, etc.)
178
+ _fallback_user="$(getent passwd 1000 2>/dev/null | cut -d: -f1 || true)"
179
+ if [[ -n "${_fallback_user}" && "${_fallback_user}" != "root" ]]; then
180
+ REAL_USER="${_fallback_user}"
181
+ REAL_HOME="$(getent passwd "${_fallback_user}" | cut -d: -f6 2>/dev/null || echo "")"
182
+ else
183
+ REAL_USER="$(id -un)"
184
+ REAL_HOME="${HOME}"
185
+ fi
186
+ unset _fallback_user
187
+ else
188
+ REAL_USER="$(id -un)"
189
+ REAL_HOME="${HOME}"
190
+ fi
191
+ [[ -z "${REAL_HOME}" ]] && REAL_HOME="${HOME}"
192
+ REAL_GID="$(id -g "${REAL_USER}" 2>/dev/null || echo "")"
193
+
194
+ # JISHUSHELL_HOME always lives in the real user's home so data is accessible
195
+ # regardless of whether the process runs as root or as that user.
196
+ if [[ -z "${JISHUSHELL_HOME:-}" ]]; then
197
+ JISHUSHELL_HOME="${REAL_HOME}/.jishushell"
198
+ fi
199
+ JISHUSHELL_BIN_DIR="${JISHUSHELL_BIN_DIR:-$JISHUSHELL_HOME/bin}"
200
+ # ──── END VERSIONS ────
201
+
202
+ # Pick tagline
203
+ TAGLINE=""
204
+ pick_tagline
205
+
206
+ # Colors
207
+ BOLD='\033[1m'
208
+ ACCENT='\033[38;2;66;135;245m'
209
+ ACCENT_CORAL='\033[38;2;255;77;77m'
210
+ INFO='\033[38;2;136;146;176m'
211
+ SUCCESS='\033[38;2;0;229;204m'
212
+ WARN='\033[38;2;255;176;32m'
213
+ ERROR='\033[38;2;230;57;70m'
214
+ MUTED='\033[38;2;90;100;128m'
215
+ NC='\033[0m'
216
+
217
+ # Shared state
218
+ VERBOSE="${VERBOSE:-0}"
219
+ DRY_RUN="${DRY_RUN:-0}"
220
+ SKIP_NODE="${SKIP_NODE:-0}"
221
+ SKIP_DOCKER="${SKIP_DOCKER:-0}"
222
+ SKIP_NOMAD="${SKIP_NOMAD:-0}"
223
+ SKIP_OPENCLAW="${SKIP_OPENCLAW:-0}" # default=0 (install); use --skip 4 or --skip-openclaw to skip
224
+ SKIP_JISHUSHELL="${SKIP_JISHUSHELL:-0}" # 1=skip install_jishushell
225
+ SKIP_JISHUSHELL_SERVICE="${SKIP_JISHUSHELL_SERVICE:-0}" # 1=skip service registration
226
+ OPENCLAW_NPM_VERSION="${OPENCLAW_NPM_VERSION:-latest}" # openclaw npm package version
227
+ OPENCLAW_DOCKER_TAG="${OPENCLAW_DOCKER_TAG:-jishushell-openclaw:local}" # locally built image with Python
228
+ OPENCLAW_IMAGE="" # set dynamically after pull/build
229
+ AUTO_YES="${AUTO_YES:-0}"
230
+ DOCKER_CMD_PREFIX="" # Set to "sg docker -c" when group activated via sg
231
+ DOCKER_GROUP_JUST_ADDED=0 # 1 if usermod was called this run
232
+ DOCKER_USE_SUDO=0 # 1 if sudo docker should be used as fallback
233
+ NETWORK_CHINA=0 # 1 if www.google.com is unreachable (use Alibaba mirrors)
234
+ NETWORK_OVERRIDE=0 # 1 if --network was explicitly passed (skip auto-detection)
235
+
236
+ PKG_UPDATED=0
237
+
238
+ INSTALL_STAGE_TOTAL="${INSTALL_STAGE_TOTAL:-6}"
239
+ INSTALL_STAGE_CURRENT="${INSTALL_STAGE_CURRENT:-0}"
240
+ JISHU_LOG_FILE=""
241
+ _JISHU_RAW_LOG=""
242
+ _JISHU_TEE_PID=""
243
+ _JISHU_LOG_FIFO=""
244
+ _JISHU_DETAIL_LOG=""
245
+
246
+ # Legacy environment variable mapping
247
+ map_legacy_env() {
248
+ local key="$1"
249
+ local legacy="$2"
250
+ if [[ -z "${!key:-}" && -n "${!legacy:-}" ]]; then
251
+ printf -v "$key" '%s' "${!legacy}"
252
+ fi
253
+ }
254
+
255
+ # (legacy env var mappings reserved for future renames)
256
+
257
+ # ─── UI helpers ───────────────────────────────────────────────────────────────
258
+ ui_info() {
259
+ echo -e "${MUTED}·${NC} $*"
260
+ }
261
+
262
+ ui_success() {
263
+ echo -e "${SUCCESS}✓${NC} $*"
264
+ }
265
+
266
+ ui_warn() {
267
+ echo -e "${WARN}!${NC} $*"
268
+ }
269
+
270
+ ui_error() {
271
+ echo -e "${ERROR}✗${NC} $*" >&2
272
+ }
273
+
274
+ ui_stage() {
275
+ local title="$1"
276
+ INSTALL_STAGE_CURRENT=$((INSTALL_STAGE_CURRENT + 1))
277
+ echo ""
278
+ echo -e "${ACCENT}${BOLD}[${INSTALL_STAGE_CURRENT}/${INSTALL_STAGE_TOTAL}] ${title}${NC}"
279
+ }
280
+
281
+ ui_section() {
282
+ echo -e "\n${ACCENT}${BOLD}── $* ──${NC}"
283
+ }
284
+
285
+ ui_kv() {
286
+ local key="$1" value="$2"
287
+ printf "${MUTED}%-18s${NC} %s\n" "$key:" "$value"
288
+ }
289
+
290
+ # ─── Detail-log helpers ───────────────────────────────────────────────────────
291
+ # Appends one line directly to the detail log file (bypasses the terminal pipe).
292
+ log_detail() {
293
+ [[ -n "${_JISHU_DETAIL_LOG:-}" ]] || return 0
294
+ printf '%s\n' "$*" >> "${_JISHU_DETAIL_LOG}"
295
+ }
296
+
297
+ # Runs a command, capturing stdout+stderr to the detail log file only.
298
+ # Nothing is written to the terminal — use ui_* helpers for status messages.
299
+ # Returns the command's exit code.
300
+ log_cmd() {
301
+ local _rc=0
302
+ if [[ -z "${_JISHU_DETAIL_LOG:-}" ]]; then
303
+ "$@" 2>&1
304
+ return
305
+ fi
306
+ log_detail ""
307
+ log_detail "[$(date '+%H:%M:%S')] \$ $*"
308
+ # Capture output to detail log only (not forwarded to the terminal)
309
+ local _tmp
310
+ _tmp="$(mktemp)"
311
+ "$@" > "${_tmp}" 2>&1 || _rc=$?
312
+ if [[ -s "${_tmp}" ]]; then
313
+ sed 's/^/ /' "${_tmp}" >> "${_JISHU_DETAIL_LOG}" # → detail log only
314
+ fi
315
+ rm -f "${_tmp}"
316
+ log_detail " \u2192 exit ${_rc}"
317
+ return $_rc
318
+ }
319
+
320
+ # Like log_cmd but prepends \$SUDO when set (mirrors run_sudo behaviour).
321
+ _log_sudo() {
322
+ if [[ -n "${SUDO:-}" ]]; then
323
+ log_cmd "${SUDO}" "$@"
324
+ else
325
+ log_cmd "$@"
326
+ fi
327
+ }
328
+
329
+ # ─── User confirmation prompt ────────────────────────────────────────────────
330
+ # Usage: confirm "Delete everything?" && do_delete
331
+ # Respects AUTO_YES=1 to skip prompts.
332
+ confirm() {
333
+ local prompt="$1"
334
+ if [[ "${AUTO_YES:-0}" == "1" ]]; then
335
+ ui_info "$prompt → auto-confirmed (--yes)"
336
+ return 0
337
+ fi
338
+ local answer answer_lc
339
+ read -r -p "$(echo -e "${WARN} ${prompt} [y/N]: ${NC}")" answer </dev/tty || answer="n"
340
+ answer_lc="$(echo "$answer" | tr '[:upper:]' '[:lower:]')"
341
+ case "$answer_lc" in
342
+ y|yes) return 0 ;;
343
+ *) return 1 ;;
344
+ esac
345
+ }
346
+
347
+ # ─── Utility functions ────────────────────────────────────────────────────────
348
+
349
+ # Detect OS and package manager.
350
+ # Sets: OS_ID, OS_VERSION, OS_NAME, PKG_MANAGER
351
+ detect_os() {
352
+ if [[ "$(uname -s)" == "Darwin" ]]; then
353
+ OS="macos"
354
+ OS_ID="macos"
355
+ OS_VERSION="$(sw_vers -productVersion 2>/dev/null || echo "unknown")"
356
+ OS_NAME="macOS ${OS_VERSION}"
357
+ if command -v brew &>/dev/null; then
358
+ PKG_MANAGER="brew"
359
+ else
360
+ PKG_MANAGER="none"
361
+ ui_warn "Homebrew not found — some optional installs may be skipped"
362
+ fi
363
+ ui_success "OS: ${OS_NAME} (package manager: ${PKG_MANAGER})"
364
+ return 0
365
+ fi
366
+
367
+ if [[ ! -f /etc/os-release ]]; then
368
+ ui_error "Cannot detect OS: /etc/os-release not found"
369
+ ui_error "This installer supports Linux and macOS"
370
+ exit 1
371
+ fi
372
+
373
+ # shellcheck source=/dev/null
374
+ . /etc/os-release
375
+
376
+ OS_ID="${ID:-unknown}"
377
+ OS_VERSION="${VERSION_ID:-unknown}"
378
+ OS_NAME="${PRETTY_NAME:-$OS_ID $OS_VERSION}"
379
+
380
+ case "$OS_ID" in
381
+ ubuntu|debian|linuxmint|pop)
382
+ PKG_MANAGER="apt"
383
+ ;;
384
+ centos|rhel|rocky|almalinux|fedora|amzn)
385
+ if command -v dnf &>/dev/null; then
386
+ PKG_MANAGER="dnf"
387
+ else
388
+ PKG_MANAGER="yum"
389
+ fi
390
+ ;;
391
+ *)
392
+ ui_warn "Untested distribution: $OS_ID — attempting auto-detect"
393
+ if command -v apt-get &>/dev/null; then
394
+ PKG_MANAGER="apt"
395
+ elif command -v dnf &>/dev/null; then
396
+ PKG_MANAGER="dnf"
397
+ elif command -v yum &>/dev/null; then
398
+ PKG_MANAGER="yum"
399
+ else
400
+ ui_error "No supported package manager found (apt/dnf/yum)"
401
+ exit 1
402
+ fi
403
+ ;;
404
+ esac
405
+
406
+ OS="linux"
407
+ ui_success "OS: ${OS_NAME} (package manager: ${PKG_MANAGER})"
408
+ }
409
+
410
+ # Detect CPU architecture. Sets: ARCH (amd64 | arm64)
411
+ detect_arch() {
412
+ ARCH="$(uname -m)"
413
+ case "$ARCH" in
414
+ x86_64|amd64) ARCH="amd64" ;;
415
+ aarch64|arm64) ARCH="arm64" ;;
416
+ *)
417
+ ui_error "Unsupported CPU architecture: $ARCH"
418
+ exit 1
419
+ ;;
420
+ esac
421
+ ui_success "Architecture: ${ARCH}"
422
+ }
423
+
424
+ # Detect network environment by pinging www.google.com. Sets: NETWORK_CHINA (0=global, 1=China)
425
+ detect_network() {
426
+ if [[ "${NETWORK_OVERRIDE:-0}" == "1" ]]; then
427
+ if [[ "$NETWORK_CHINA" == "1" ]]; then
428
+ ui_info "Network: forced china (--network china) — Alibaba mirrors will be used"
429
+ else
430
+ ui_info "Network: forced global (--network global)"
431
+ fi
432
+ return 0
433
+ fi
434
+ ui_info "Checking network connectivity (ping www.google.com)..."
435
+ if ping -c 1 -W 3 www.google.com &>/dev/null 2>&1; then
436
+ NETWORK_CHINA=0
437
+ ui_success "Network: global reachable"
438
+ else
439
+ NETWORK_CHINA=1
440
+ ui_warn "Network: www.google.com unreachable — Alibaba mirrors will be used for Docker and OpenClaw"
441
+ fi
442
+ }
443
+
444
+ # Verify sudo access. Sets: SUDO ("" if root, "sudo" otherwise)
445
+ check_sudo() {
446
+ if [[ $EUID -eq 0 ]]; then
447
+ SUDO=""
448
+ return 0
449
+ fi
450
+
451
+ if ! command -v sudo &>/dev/null; then
452
+ ui_error "Not running as root and sudo is not installed. Re-run as root."
453
+ exit 1
454
+ fi
455
+
456
+ if ! sudo -n true 2>/dev/null; then
457
+ ui_info "Some steps require sudo — you may be prompted for your password."
458
+ if ! sudo -v </dev/tty; then
459
+ ui_error "Failed to obtain sudo privileges"
460
+ exit 1
461
+ fi
462
+ fi
463
+
464
+ SUDO="sudo"
465
+ ui_success "sudo privileges confirmed"
466
+
467
+ # Keep sudo credentials alive for the entire install (refreshes every 60s).
468
+ # This ensures 'sudo docker' works even after a long Docker install step
469
+ # without prompting for the password again.
470
+ if [[ -z "${_SUDO_KEEPALIVE_PID:-}" ]]; then
471
+ ( while true; do sudo -n true 2>/dev/null; sleep 60; done ) &
472
+ _SUDO_KEEPALIVE_PID=$!
473
+ disown "$_SUDO_KEEPALIVE_PID" 2>/dev/null || true
474
+ fi
475
+ }
476
+
477
+ extract_semver() {
478
+ grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1
479
+ }
480
+
481
+ # Semantic version comparison: returns 0 if $1 >= $2
482
+ version_gte() {
483
+ local v1="$1" v2="$2"
484
+ if [[ "$(printf '%s\n%s' "$v1" "$v2" | sort -V | head -n1)" == "$v2" ]]; then
485
+ return 0
486
+ fi
487
+ return 1
488
+ }
489
+
490
+ # Run a command, honouring --dry-run.
491
+ # When the detail log is active, command output is captured there (silent on terminal).
492
+ run_cmd() {
493
+ if [[ "$DRY_RUN" == "1" ]]; then
494
+ ui_info "[dry-run] $*"
495
+ return 0
496
+ fi
497
+ log_detail ""
498
+ log_detail "[$(date '+%H:%M:%S')] \$ $*"
499
+ if [[ -n "${_JISHU_DETAIL_LOG:-}" ]]; then
500
+ local _rc=0 _tmp
501
+ _tmp="$(mktemp)"
502
+ "$@" > "${_tmp}" 2>&1 || _rc=$?
503
+ [[ -s "${_tmp}" ]] && sed 's/^/ /' "${_tmp}" >> "${_JISHU_DETAIL_LOG}"
504
+ rm -f "${_tmp}"
505
+ log_detail " \u2192 exit ${_rc}"
506
+ return $_rc
507
+ fi
508
+ "$@"
509
+ }
510
+
511
+ # Run a command with sudo, honouring --dry-run.
512
+ # When the detail log is active, command output is captured there (silent on terminal).
513
+ run_sudo() {
514
+ if [[ "$DRY_RUN" == "1" ]]; then
515
+ ui_info "[dry-run] ${SUDO:+sudo }$*"
516
+ return 0
517
+ fi
518
+ log_detail ""
519
+ log_detail "[$(date '+%H:%M:%S')] \$ ${SUDO:+${SUDO} }$*"
520
+ if [[ -n "${_JISHU_DETAIL_LOG:-}" ]]; then
521
+ local _rc=0 _tmp
522
+ _tmp="$(mktemp)"
523
+ ${SUDO} "$@" > "${_tmp}" 2>&1 || _rc=$?
524
+ [[ -s "${_tmp}" ]] && sed 's/^/ /' "${_tmp}" >> "${_JISHU_DETAIL_LOG}"
525
+ rm -f "${_tmp}"
526
+ log_detail " \u2192 exit ${_rc}"
527
+ return $_rc
528
+ fi
529
+ ${SUDO} "$@"
530
+ }
531
+
532
+ # Wait for apt/dpkg locks to be released
533
+ wait_for_apt_lock() {
534
+ if [[ "${PKG_MANAGER:-}" != "apt" ]]; then
535
+ return 0
536
+ fi
537
+
538
+ local max_wait=60
539
+ local waited=0
540
+
541
+ while fuser /var/lib/dpkg/lock-frontend &>/dev/null 2>&1 || \
542
+ fuser /var/lib/apt/lists/lock &>/dev/null 2>&1; do
543
+ if [[ $waited -eq 0 ]]; then
544
+ ui_info "Waiting for apt lock to be released..."
545
+ fi
546
+ sleep 2
547
+ waited=$((waited + 2))
548
+ if [[ $waited -ge $max_wait ]]; then
549
+ ui_error "Timed out waiting for apt lock (${max_wait}s). Check for other running package managers."
550
+ exit 1
551
+ fi
552
+ done
553
+ }
554
+
555
+ # Run the package index update once per invocation.
556
+ # Safe to call multiple times — only executes on the first call.
557
+ pkg_update() {
558
+ if [[ "$PKG_UPDATED" == "1" ]]; then
559
+ return 0
560
+ fi
561
+
562
+ if [[ "$DRY_RUN" == "1" ]]; then
563
+ ui_info "[dry-run] Would update package index"
564
+ PKG_UPDATED=1
565
+ return 0
566
+ fi
567
+
568
+ wait_for_apt_lock
569
+
570
+ ui_info "Updating package index..."
571
+ case "$PKG_MANAGER" in
572
+ apt)
573
+ if ! retry_net "apt-get update" 3 _log_sudo apt-get update; then
574
+ ui_warn "apt-get update failed — package index may be stale, continuing anyway"
575
+ fi
576
+ ;;
577
+ dnf)
578
+ retry_net "dnf check-update" 3 _log_sudo dnf check-update 2>/dev/null || true
579
+ ;;
580
+ yum)
581
+ retry_net "yum check-update" 3 _log_sudo yum check-update 2>/dev/null || true
582
+ ;;
583
+ brew)
584
+ retry_net "brew update" 3 log_cmd brew update 2>/dev/null || true
585
+ ;;
586
+ none)
587
+ ;;
588
+ esac
589
+
590
+ PKG_UPDATED=1
591
+ }
592
+
593
+ # Install system packages, ensuring the index is up to date first.
594
+ pkg_install() {
595
+ pkg_update
596
+ wait_for_apt_lock
597
+ case "$PKG_MANAGER" in
598
+ apt)
599
+ if ! retry_net "apt-get install $*" 3 run_sudo apt-get install -y "$@"; then
600
+ ui_error "apt-get install failed for: $*"
601
+ return 1
602
+ fi
603
+ ;;
604
+ dnf)
605
+ if ! retry_net "dnf install $*" 3 run_sudo dnf install -y "$@"; then
606
+ ui_error "dnf install failed for: $*"
607
+ return 1
608
+ fi
609
+ ;;
610
+ yum)
611
+ if ! retry_net "yum install $*" 3 run_sudo yum install -y "$@"; then
612
+ ui_error "yum install failed for: $*"
613
+ return 1
614
+ fi
615
+ ;;
616
+ brew)
617
+ if ! retry_net "brew install $*" 3 brew install "$@" 2>/dev/null; then
618
+ ui_warn "brew install failed for: $* (may already be installed)"
619
+ fi
620
+ ;;
621
+ none)
622
+ ui_warn "No package manager available — skipping install of: $*"
623
+ ;;
624
+ esac
625
+ }
626
+
627
+ # Check whether a system package is installed
628
+ _pkg_is_installed() {
629
+ local pkg="$1"
630
+ case "$PKG_MANAGER" in
631
+ apt) dpkg -l "$pkg" 2>/dev/null | grep -q '^ii' ;;
632
+ dnf) rpm -q "$pkg" &>/dev/null ;;
633
+ yum) rpm -q "$pkg" &>/dev/null ;;
634
+ esac
635
+ }
636
+
637
+ # ═══════════════════════════════════════════════════════════════════════════════
638
+ # npm install failure detection and auto-fix
639
+ # ═══════════════════════════════════════════════════════════════════════════════
640
+ npm_log_indicates_missing_build_tools() {
641
+ local log="$1"
642
+ if [[ -z "$log" || ! -f "$log" ]]; then
643
+ return 1
644
+ fi
645
+ grep -Eiq "(not found: make|make: command not found|cmake: command not found|CMAKE_MAKE_PROGRAM is not set|Could not find CMAKE|gyp ERR! find Python|no developer tools were found|is not able to compile a simple test program|Failed to build|It seems that \"make\" is not installed|It seems that the used \"cmake\" doesn't work properly)" "$log"
646
+ }
647
+
648
+ # Detect Arch-based distributions
649
+ is_arch_linux() {
650
+ if [[ -f /etc/os-release ]]; then
651
+ local os_id
652
+ os_id="$(grep -E '^ID=' /etc/os-release 2>/dev/null | cut -d'=' -f2 | tr -d '"' || true)"
653
+ case "$os_id" in
654
+ arch|manjaro|endeavouros|arcolinux|garuda|archarm|cachyos|archcraft)
655
+ return 0
656
+ esac
657
+ local os_id_like
658
+ os_id_like="$(grep -E '^ID_LIKE=' /etc/os-release 2>/dev/null | cut -d'=' -f2 | tr -d '"' || true)"
659
+ if [[ "$os_id_like" == *arch* ]]; then
660
+ return 0
661
+ fi
662
+ fi
663
+ if command -v pacman &> /dev/null; then
664
+ return 0
665
+ fi
666
+ return 1
667
+ }
668
+
669
+ install_build_tools_linux() {
670
+ if command -v apt-get &> /dev/null; then
671
+ if is_root; then
672
+ run_quiet_step "Updating package index" apt-get update -qq
673
+ run_quiet_step "Installing build tools" apt-get install -y -qq build-essential python3 make g++ cmake
674
+ else
675
+ run_quiet_step "Updating package index" sudo apt-get update -qq
676
+ run_quiet_step "Installing build tools" sudo apt-get install -y -qq build-essential python3 make g++ cmake
677
+ fi
678
+ return 0
679
+ fi
680
+ if command -v pacman &> /dev/null || is_arch_linux; then
681
+ if is_root; then
682
+ run_quiet_step "Installing build tools" pacman -Sy --noconfirm base-devel python make cmake gcc
683
+ else
684
+ run_quiet_step "Installing build tools" sudo pacman -Sy --noconfirm base-devel python make cmake gcc
685
+ fi
686
+ return 0
687
+ fi
688
+ if command -v dnf &> /dev/null; then
689
+ if is_root; then
690
+ run_quiet_step "Installing build tools" dnf install -y -q gcc gcc-c++ make cmake python3
691
+ else
692
+ run_quiet_step "Installing build tools" sudo dnf install -y -q gcc gcc-c++ make cmake python3
693
+ fi
694
+ return 0
695
+ fi
696
+ if command -v yum &> /dev/null; then
697
+ if is_root; then
698
+ run_quiet_step "Installing build tools" yum install -y -q gcc gcc-c++ make cmake python3
699
+ else
700
+ run_quiet_step "Installing build tools" sudo yum install -y -q gcc gcc-c++ make cmake python3
701
+ fi
702
+ return 0
703
+ fi
704
+ if command -v apk &> /dev/null; then
705
+ if is_root; then
706
+ run_quiet_step "Installing build tools" apk add --no-cache build-base python3 cmake
707
+ else
708
+ run_quiet_step "Installing build tools" sudo apk add --no-cache build-base python3 cmake
709
+ fi
710
+ return 0
711
+ fi
712
+ ui_warn "Could not detect package manager for auto-installing build tools"
713
+ return 1
714
+ }
715
+
716
+ install_build_tools_macos() {
717
+ local ok=true
718
+ if ! xcode-select -p >/dev/null 2>&1; then
719
+ ui_info "Installing Xcode Command Line Tools (required for make/clang)"
720
+ xcode-select --install >/dev/null 2>&1 || true
721
+ if ! xcode-select -p >/dev/null 2>&1; then
722
+ ui_warn "Xcode Command Line Tools are not ready yet"
723
+ ui_info "Complete the installer dialog, then re-run this installer"
724
+ ok=false
725
+ fi
726
+ fi
727
+ if ! command -v cmake >/dev/null 2>&1; then
728
+ if command -v brew >/dev/null 2>&1; then
729
+ run_quiet_step "Installing cmake" brew install cmake
730
+ else
731
+ ui_warn "Homebrew not available; cannot auto-install cmake"
732
+ ok=false
733
+ fi
734
+ fi
735
+ if ! command -v make >/dev/null 2>&1; then
736
+ ui_warn "make is still unavailable"
737
+ ok=false
738
+ fi
739
+ if ! command -v cmake >/dev/null 2>&1; then
740
+ ui_warn "cmake is still unavailable"
741
+ ok=false
742
+ fi
743
+ [[ "$ok" == "true" ]]
744
+ }
745
+
746
+ is_root() {
747
+ [[ $EUID -eq 0 ]]
748
+ }
749
+
750
+ run_quiet_step() {
751
+ local title="$1"
752
+ shift
753
+ if [[ "$DRY_RUN" == "1" ]]; then
754
+ ui_info "[dry-run] $title"
755
+ return 0
756
+ fi
757
+ log_detail ""
758
+ log_detail "[$(date '+%H:%M:%S')] \$ $* # ${title}"
759
+ if "$@"; then
760
+ return 0
761
+ fi
762
+ ui_error "${title} failed"
763
+ return 1
764
+ }
765
+
766
+ auto_install_build_tools_for_npm_failure() {
767
+ local log="$1"
768
+ if ! npm_log_indicates_missing_build_tools "$log"; then
769
+ return 1
770
+ fi
771
+ ui_warn "Detected missing native build tools; attempting automatic setup"
772
+ if [[ "$OS" == "linux" ]]; then
773
+ install_build_tools_linux || return 1
774
+ elif [[ "$OS" == "macos" ]]; then
775
+ install_build_tools_macos || return 1
776
+ else
777
+ return 1
778
+ fi
779
+ ui_success "Build tools setup complete"
780
+ return 0
781
+ }
782
+
783
+ extract_npm_error_code() {
784
+ local log="$1"
785
+ sed -n -E 's/^npm (ERR!|error) code[[:space:]]+([^[:space:]]+).*$/\2/p' "$log" | head -n1
786
+ }
787
+
788
+ print_npm_failure_diagnostics() {
789
+ local spec="$1"
790
+ local log="$2"
791
+ local error_code=""
792
+ ui_warn "npm install failed for ${spec}"
793
+ error_code="$(extract_npm_error_code "$log")"
794
+ if [[ -n "$error_code" ]]; then
795
+ echo " npm code: ${error_code}"
796
+ fi
797
+ if [[ -s "$log" ]]; then
798
+ echo " Last lines of log:"
799
+ tail -n 20 "$log" | sed 's/^/ /' || true
800
+ fi
801
+ }
802
+
803
+ # ─── Prerequisites ────────────────────────────────────────────────────────────
804
+ ensure_prerequisites() {
805
+ local missing=()
806
+
807
+ for cmd in curl tar; do
808
+ if ! command -v "$cmd" &>/dev/null; then
809
+ missing+=("$cmd")
810
+ fi
811
+ done
812
+
813
+ if [[ ${#missing[@]} -gt 0 ]]; then
814
+ ui_info "Installing prerequisites: ${missing[*]}"
815
+ pkg_install "${missing[@]}"
816
+ fi
817
+
818
+ if ! command -v curl &>/dev/null; then
819
+ ui_error "Failed to install curl. Please install it manually and retry."
820
+ exit 1
821
+ fi
822
+ }
823
+
824
+ # ═══════════════════════════════════════════════════════════════════════════════
825
+ # Component installation functions
826
+ # ═══════════════════════════════════════════════════════════════════════════════
827
+
828
+ # ─── 1. Node.js (via nvm) ────────────────────────────────────────────────────
829
+
830
+ _load_nvm() {
831
+ export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
832
+ if [[ -s "$NVM_DIR/nvm.sh" ]]; then
833
+ # shellcheck source=/dev/null
834
+ \. "$NVM_DIR/nvm.sh"
835
+ return 0
836
+ fi
837
+ return 1
838
+ }
839
+
840
+ install_node() {
841
+ ui_stage "Node.js (via nvm)"
842
+
843
+ _load_nvm 2>/dev/null || true
844
+
845
+ if command -v node &>/dev/null; then
846
+ local current_version
847
+ current_version="$(node --version 2>/dev/null | sed 's/^v//')"
848
+ local current_major="${current_version%%.*}"
849
+
850
+ if [[ "$current_major" -ge "$NODE_VERSION" ]]; then
851
+ ui_success "Node.js already installed: v${current_version} (satisfies >= v${NODE_VERSION})"
852
+ if command -v npm &>/dev/null; then
853
+ ui_success "npm: $(npm --version 2>/dev/null)"
854
+ fi
855
+ _ensure_nvm_shell_config
856
+ return 0
857
+ else
858
+ ui_warn "Node.js version too old: v${current_version} (need >= v${NODE_VERSION})"
859
+ ui_info "Upgrading Node.js via nvm..."
860
+ fi
861
+ else
862
+ ui_info "Node.js not found — installing via nvm..."
863
+ fi
864
+
865
+ _do_install_node
866
+ }
867
+
868
+ _do_install_node() {
869
+ ui_info "Installing nvm v${NVM_VERSION} and Node.js ${NODE_VERSION}..."
870
+
871
+ if [[ "$DRY_RUN" == "1" ]]; then
872
+ ui_info "[dry-run] Would install nvm v${NVM_VERSION}"
873
+ ui_info "[dry-run] Would run: nvm install ${NODE_VERSION}"
874
+ return 0
875
+ fi
876
+
877
+ local nvm_install_script
878
+ nvm_install_script="$(mktemp)"
879
+ trap "rm -f '$nvm_install_script'" RETURN
880
+
881
+ local nvm_url
882
+ if [[ "${NETWORK_CHINA:-0}" == "1" ]]; then
883
+ # Gitee mirror of nvm-sh/nvm (Chinese CDN)
884
+ nvm_url="https://gitee.com/mirrors/nvm/raw/v${NVM_VERSION}/install.sh"
885
+ ui_info "China network — downloading nvm from Gitee mirror"
886
+ else
887
+ nvm_url="https://raw.githubusercontent.com/nvm-sh/nvm/v${NVM_VERSION}/install.sh"
888
+ fi
889
+ if ! retry_net "Download nvm install script" 3 curl -fsSL "$nvm_url" -o "$nvm_install_script"; then
890
+ ui_error "Failed to download nvm install script from: $nvm_url"
891
+ return 1
892
+ fi
893
+
894
+ if [[ "${NETWORK_CHINA:-0}" == "1" ]]; then
895
+ # Point nvm at the npmmirror.com Node.js CDN (much faster in China)
896
+ export NVM_NODEJS_ORG_MIRROR="https://npmmirror.com/mirrors/node"
897
+ ui_info "China network — nvm will download Node.js from npmmirror.com"
898
+ fi
899
+
900
+ if ! bash "$nvm_install_script"; then
901
+ ui_error "nvm install script failed"
902
+ return 1
903
+ fi
904
+
905
+ rm -f "$nvm_install_script"
906
+ trap - RETURN
907
+
908
+ if ! _load_nvm; then
909
+ ui_error "nvm was installed but could not be loaded (NVM_DIR=$NVM_DIR)"
910
+ return 1
911
+ fi
912
+
913
+ ui_success "nvm loaded: $(nvm --version 2>/dev/null)"
914
+
915
+ ui_info "Running: nvm install ${NODE_VERSION}"
916
+ if ! retry_net "nvm install ${NODE_VERSION}" 3 nvm install "${NODE_VERSION}"; then
917
+ ui_error "nvm install ${NODE_VERSION} failed"
918
+ return 1
919
+ fi
920
+
921
+ nvm alias default "${NODE_VERSION}" >/dev/null 2>&1 || true
922
+
923
+ local installed_version
924
+ installed_version="$(node --version 2>/dev/null)"
925
+ if [[ -z "$installed_version" ]]; then
926
+ ui_error "Node.js installation verification failed"
927
+ return 1
928
+ fi
929
+ ui_success "Node.js installed: ${installed_version}"
930
+
931
+ if command -v npm &>/dev/null; then
932
+ ui_success "npm: $(npm --version 2>/dev/null)"
933
+ else
934
+ ui_warn "npm was not found after installation — check manually"
935
+ fi
936
+
937
+ _ensure_nvm_shell_config
938
+ }
939
+
940
+ _ensure_nvm_shell_config() {
941
+ local init_block
942
+ init_block='\nexport NVM_DIR="$HOME/.nvm"\n[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"\n[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"'
943
+
944
+ local rc_files=("$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile" "$HOME/.zshrc")
945
+ local added=0
946
+ for rc in "${rc_files[@]}"; do
947
+ if [[ -f "$rc" ]] && ! grep -q 'NVM_DIR' "$rc" 2>/dev/null; then
948
+ printf '%b\n' "$init_block" >> "$rc"
949
+ ui_info "nvm init added to $rc"
950
+ added=1
951
+ fi
952
+ done
953
+ if [[ $added -eq 0 ]]; then
954
+ :
955
+ fi
956
+ }
957
+
958
+ # ─── 2. Docker ───────────────────────────────────────────────────────────────
959
+
960
+ install_docker() {
961
+ ui_stage "Docker"
962
+
963
+ local need_install_docker=0
964
+ local need_install_compose=0
965
+
966
+ if command -v docker &>/dev/null; then
967
+ local docker_version
968
+ docker_version="$(docker version --format '{{.Server.Version}}' 2>/dev/null | extract_semver || \
969
+ docker --version 2>/dev/null | extract_semver || \
970
+ echo "unknown")"
971
+
972
+ if [[ "$docker_version" != "unknown" ]]; then
973
+ ui_success "Docker already installed: v${docker_version}"
974
+ else
975
+ ui_warn "Docker command found but version unavailable (daemon may not be running)"
976
+ _ensure_docker_running
977
+ fi
978
+ # Always ensure mirrors are configured when NETWORK_CHINA=1,
979
+ # even if Docker was already installed before this run.
980
+ _configure_docker_mirror
981
+ else
982
+ need_install_docker=1
983
+ fi
984
+
985
+ if docker compose version &>/dev/null 2>&1; then
986
+ :
987
+ elif command -v docker-compose &>/dev/null; then
988
+ :
989
+ elif [[ "$OS" == "macos" ]]; then
990
+ :
991
+ else
992
+ need_install_compose=1
993
+ fi
994
+
995
+ if [[ $need_install_docker -eq 0 && $need_install_compose -eq 0 ]]; then
996
+ _ensure_docker_running
997
+ _ensure_docker_group
998
+ return 0
999
+ fi
1000
+
1001
+ if [[ $need_install_docker -eq 1 ]]; then
1002
+ ui_info "Docker not found — installing..."
1003
+ # _do_install_docker installs docker-compose-plugin in the same apt command,
1004
+ # so Compose V2 will be available immediately after — no separate step needed.
1005
+ if ! _do_install_docker; then
1006
+ ui_warn "Official Docker install script failed — trying system package manager fallback..."
1007
+ if ! _do_install_docker_apt_fallback; then
1008
+ ui_error "All Docker installation methods failed — skipping group setup"
1009
+ return 1
1010
+ fi
1011
+ fi
1012
+ # Compose is bundled; skip the separate install step
1013
+ need_install_compose=0
1014
+ fi
1015
+
1016
+ if [[ $need_install_compose -eq 1 ]]; then
1017
+ if ! docker compose version &>/dev/null 2>&1; then
1018
+ ui_info "Docker Compose V2 not detected — installing plugin..."
1019
+ if ! _do_install_compose_plugin; then
1020
+ ui_warn "Docker Compose V2 plugin installation failed — Compose features may be unavailable"
1021
+ fi
1022
+ fi
1023
+ fi
1024
+
1025
+ _ensure_docker_running
1026
+ _configure_docker_mirror
1027
+ _ensure_docker_group
1028
+ }
1029
+
1030
+ _do_install_docker() {
1031
+ if [[ "$DRY_RUN" == "1" ]]; then
1032
+ ui_info "[dry-run] Would install Docker via get.docker.com"
1033
+ return 0
1034
+ fi
1035
+
1036
+ if [[ "$OS" == "macos" ]]; then
1037
+ ui_warn "Automated Docker installation is not supported on macOS"
1038
+ ui_info "Please install Docker Desktop from https://www.docker.com/products/docker-desktop/"
1039
+ if command -v brew &>/dev/null; then
1040
+ ui_info "Or via Homebrew: brew install --cask docker"
1041
+ fi
1042
+ ui_info "After installation, open Docker Desktop and wait for the daemon to start, then re-run this script"
1043
+ return 1
1044
+ fi
1045
+
1046
+ # Step 1: download
1047
+ local docker_script
1048
+ docker_script="$(mktemp)"
1049
+ mv "$docker_script" "${docker_script}.sh"
1050
+ docker_script="${docker_script}.sh"
1051
+ ui_info "Downloading Docker install script from https://get.docker.com ..."
1052
+ if ! retry_net "Download Docker install script" 3 curl -fsSL https://get.docker.com -o "$docker_script"; then
1053
+ ui_error "Failed to download Docker install script"
1054
+ rm -f "$docker_script"
1055
+ return 1
1056
+ fi
1057
+
1058
+ # Step 2: preview (first 5 lines so the user can see what will run)
1059
+ ui_info "Install script preview (first 5 lines):"
1060
+ head -n 5 "$docker_script" | sed 's/^/ /' || true
1061
+
1062
+ # Step 3: run with sudo sh (as recommended by get.docker.com)
1063
+ local _docker_mirror_flag=""
1064
+ if [[ "${NETWORK_CHINA:-0}" == "1" ]]; then
1065
+ _docker_mirror_flag="--mirror Aliyun"
1066
+ ui_info "China network detected — using Alibaba mirror (mirrors.aliyun.com)"
1067
+ fi
1068
+ ui_info "Running: sudo sh install-docker.sh ${_docker_mirror_flag}..."
1069
+ # shellcheck disable=SC2086
1070
+ if ! ${SUDO} sh "$docker_script" ${_docker_mirror_flag}; then
1071
+ ui_error "Docker install script failed"
1072
+ rm -f "$docker_script"
1073
+ return 1
1074
+ fi
1075
+ rm -f "$docker_script"
1076
+
1077
+ # ── Post-install steps ────────────────────────────────────────────────────
1078
+ # Always add REAL_USER (the non-root user who invoked sudo), not whoami which
1079
+ # may return "root" when running via "sudo bash install.sh".
1080
+ local user="${REAL_USER:-$(whoami)}"
1081
+
1082
+ # 1. sudo usermod -aG docker $REAL_USER
1083
+ ui_info "Running: sudo usermod -aG docker ${user}"
1084
+ if ${SUDO} usermod -aG docker "${user}"; then
1085
+ ui_success "User ${user} added to docker group"
1086
+ DOCKER_GROUP_JUST_ADDED=1
1087
+ else
1088
+ ui_warn "usermod failed — run manually: sudo usermod -aG docker ${user}"
1089
+ fi
1090
+
1091
+ # 2. sudo systemctl start docker
1092
+ ui_info "Running: sudo systemctl start docker"
1093
+ ${SUDO} systemctl start docker 2>/dev/null || \
1094
+ ${SUDO} service docker start 2>/dev/null || true
1095
+
1096
+ _ensure_docker_running
1097
+
1098
+ # 3. newgrp docker (in a script 'sg docker -c' is the non-interactive equivalent)
1099
+ # Activates group membership without requiring re-login
1100
+ ui_info "Activating docker group (newgrp docker equivalent via 'sg docker') ..."
1101
+ if command -v sg &>/dev/null && sg docker -c "docker info" &>/dev/null 2>&1; then
1102
+ DOCKER_CMD_PREFIX="sg docker -c"
1103
+ ui_success "docker group activated for this session"
1104
+ else
1105
+ DOCKER_USE_SUDO=1
1106
+ ui_info "sg docker unavailable — using sudo docker for this session"
1107
+ fi
1108
+
1109
+ # 4. docker ps
1110
+ ui_info "Running: docker ps"
1111
+ local docker_ps_out
1112
+ if [[ -n "${DOCKER_CMD_PREFIX:-}" ]]; then
1113
+ docker_ps_out="$(${DOCKER_CMD_PREFIX} "docker ps" 2>&1)" && {
1114
+ ui_success "docker ps succeeded:"
1115
+ echo "$docker_ps_out" | sed 's/^/ /'
1116
+ } || ui_warn "docker ps returned non-zero (daemon may still be warming up)"
1117
+ elif ${SUDO} docker ps &>/dev/null 2>&1; then
1118
+ docker_ps_out="$(${SUDO} docker ps 2>&1)"
1119
+ ui_success "docker ps succeeded (via sudo):"
1120
+ echo "$docker_ps_out" | sed 's/^/ /'
1121
+ else
1122
+ ui_warn "docker ps failed — run 'sudo docker ps' to check daemon status"
1123
+ fi
1124
+
1125
+ if ! command -v docker &>/dev/null; then
1126
+ ui_error "Docker installation verification failed"
1127
+ return 1
1128
+ fi
1129
+
1130
+ local installed_version
1131
+ installed_version="$(docker --version 2>/dev/null | extract_semver || echo "unknown")"
1132
+ ui_success "Docker installed: v${installed_version}"
1133
+ }
1134
+
1135
+ # Fallback Docker installer that uses the system package manager (apt/dnf/yum).
1136
+ # Called when the get.docker.com script fails (network issues, GPG errors, etc.).
1137
+ _do_install_docker_apt_fallback() {
1138
+ if [[ "$DRY_RUN" == "1" ]]; then
1139
+ ui_info "[dry-run] Would install Docker via system package manager (apt/dnf/yum)"
1140
+ return 0
1141
+ fi
1142
+
1143
+ if [[ "$OS" == "macos" ]]; then
1144
+ ui_warn "No apt/dnf fallback available on macOS — please install Docker Desktop manually"
1145
+ return 1
1146
+ fi
1147
+
1148
+ case "${PKG_MANAGER:-apt}" in
1149
+ apt)
1150
+ ui_info "Attempting: apt-get install docker.io ..."
1151
+ ${SUDO} apt-get update -qq 2>/dev/null || true
1152
+ if ${SUDO} apt-get install -y docker.io docker-compose; then
1153
+ ui_success "Docker installed via apt (docker.io)"
1154
+ # Ensure service is started
1155
+ ${SUDO} systemctl start docker 2>/dev/null || \
1156
+ ${SUDO} service docker start 2>/dev/null || true
1157
+ _ensure_docker_running
1158
+ return 0
1159
+ fi
1160
+ ui_error "apt-get install docker.io failed"
1161
+ return 1
1162
+ ;;
1163
+ dnf|yum)
1164
+ ui_info "Attempting: ${PKG_MANAGER} install docker ..."
1165
+ if ${SUDO} "${PKG_MANAGER}" install -y docker; then
1166
+ ui_success "Docker installed via ${PKG_MANAGER}"
1167
+ ${SUDO} systemctl start docker 2>/dev/null || true
1168
+ _ensure_docker_running
1169
+ return 0
1170
+ fi
1171
+ ui_error "${PKG_MANAGER} install docker failed"
1172
+ return 1
1173
+ ;;
1174
+ *)
1175
+ ui_error "No fallback Docker install method for package manager: ${PKG_MANAGER:-unknown}"
1176
+ return 1
1177
+ ;;
1178
+ esac
1179
+ }
1180
+
1181
+ _do_install_compose_plugin() {
1182
+ if [[ "$DRY_RUN" == "1" ]]; then
1183
+ ui_info "[dry-run] Would install docker-compose-plugin"
1184
+ return 0
1185
+ fi
1186
+
1187
+ case "$PKG_MANAGER" in
1188
+ apt) pkg_install docker-compose-plugin ;;
1189
+ dnf|yum) pkg_install docker-compose-plugin ;;
1190
+ esac
1191
+
1192
+ if docker compose version &>/dev/null 2>&1; then
1193
+ ui_success "Docker Compose V2 installed: $(docker compose version --short 2>/dev/null)"
1194
+ else
1195
+ ui_warn "Docker Compose V2 plugin installed but not yet active — please check manually"
1196
+ fi
1197
+ }
1198
+
1199
+ # Configure Docker daemon registry mirrors when NETWORK_CHINA=1.
1200
+ # Writes /etc/docker/daemon.json; completely replaces registry-mirrors key.
1201
+ _configure_docker_mirror() {
1202
+ if [[ "${NETWORK_CHINA:-0}" != "1" ]]; then
1203
+ return 0
1204
+ fi
1205
+ if [[ "$DRY_RUN" == "1" ]]; then
1206
+ ui_info "[dry-run] Would configure Docker daemon mirrors for China"
1207
+ return 0
1208
+ fi
1209
+
1210
+ local daemon_json="/etc/docker/daemon.json"
1211
+ local mirrors_json='["https://docker.m.daocloud.io","https://hub-mirror.c.163.com","https://mirror.baidubce.com"]'
1212
+
1213
+ ui_info "Configuring Docker daemon registry mirrors (China)..."
1214
+ ${SUDO} mkdir -p /etc/docker
1215
+
1216
+ # Merge into existing config preserving other keys, then overwrite registry-mirrors.
1217
+ # Note: node -e uses double-quoted string so shell variables expand correctly.
1218
+ local tmp_cfg
1219
+ tmp_cfg="$(mktemp)"
1220
+ if [[ -f "$daemon_json" ]]; then
1221
+ node -e "
1222
+ const fs = require('fs');
1223
+ let cfg = {};
1224
+ try { cfg = JSON.parse(fs.readFileSync('${daemon_json}', 'utf8')); } catch (e) {}
1225
+ cfg['registry-mirrors'] = ${mirrors_json};
1226
+ fs.writeFileSync('${tmp_cfg}', JSON.stringify(cfg, null, 2) + '\\n');
1227
+ " 2>/dev/null || printf '{"registry-mirrors":%s}\n' "${mirrors_json}" > "$tmp_cfg"
1228
+ else
1229
+ printf '{"registry-mirrors":%s}\n' "${mirrors_json}" > "$tmp_cfg"
1230
+ fi
1231
+ ${SUDO} cp "$tmp_cfg" "$daemon_json"
1232
+ rm -f "$tmp_cfg"
1233
+
1234
+ # Reload daemon to apply new config
1235
+ if command -v systemctl &>/dev/null; then
1236
+ ${SUDO} systemctl daemon-reload 2>/dev/null || true
1237
+ ${SUDO} systemctl restart docker 2>/dev/null || true
1238
+ sleep 2
1239
+ fi
1240
+ ui_success "Docker daemon mirrors configured: daocloud.io, 163.com, baidubce.com"
1241
+ }
1242
+
1243
+ _ensure_docker_running() {
1244
+ if [[ "$DRY_RUN" == "1" ]]; then
1245
+ return 0
1246
+ fi
1247
+
1248
+ if [[ "$OS" == "macos" ]]; then
1249
+ local waited=0
1250
+ while ! docker info &>/dev/null 2>&1; do
1251
+ if [[ $waited -ge 15 ]]; then
1252
+ ui_warn "Docker daemon did not become ready within 15 seconds"
1253
+ ui_info "Make sure Docker Desktop is open and running"
1254
+ return 1
1255
+ fi
1256
+ sleep 1
1257
+ (( waited++ )) || true
1258
+ done
1259
+ [[ $waited -lt 15 ]] && ui_success "Docker daemon is ready"
1260
+ return 0
1261
+ fi
1262
+
1263
+ if command -v systemctl &>/dev/null; then
1264
+ # Reset any failed state left by previous install attempts
1265
+ ${SUDO} systemctl reset-failed docker.socket docker.service 2>/dev/null || true
1266
+
1267
+ # Start socket-activated unit first (prevents "dependency failed" errors)
1268
+ if ! systemctl is-active --quiet docker.socket 2>/dev/null; then
1269
+ ui_info "Starting Docker socket..."
1270
+ run_sudo systemctl start docker.socket 2>/dev/null || true
1271
+ sleep 1
1272
+ fi
1273
+
1274
+ if ! systemctl is-active --quiet docker 2>/dev/null; then
1275
+ ui_info "Starting Docker service..."
1276
+ if ! ${SUDO} systemctl start docker; then
1277
+ ui_error "Failed to start Docker service — check: sudo journalctl -xe"
1278
+ else
1279
+ ui_success "Docker service started"
1280
+ fi
1281
+ fi
1282
+ if ! systemctl is-enabled --quiet docker 2>/dev/null; then
1283
+ ui_info "Enabling Docker on startup..."
1284
+ ${SUDO} systemctl enable docker 2>/dev/null || true
1285
+ fi
1286
+ if ! systemctl is-enabled --quiet docker.socket 2>/dev/null; then
1287
+ ${SUDO} systemctl enable docker.socket 2>/dev/null || true
1288
+ fi
1289
+
1290
+ # Wait up to 15s for Docker daemon to be ready to accept connections
1291
+ local waited=0
1292
+ while ! ${SUDO} docker info &>/dev/null 2>&1; do
1293
+ if [[ $waited -ge 15 ]]; then
1294
+ ui_warn "Docker daemon did not become ready within 15 seconds"
1295
+ break
1296
+ fi
1297
+ sleep 1
1298
+ (( waited++ )) || true
1299
+ done
1300
+ [[ $waited -lt 15 ]] && ui_success "Docker daemon is ready"
1301
+ else
1302
+ if ! ${SUDO} service docker status &>/dev/null; then
1303
+ ui_info "Starting Docker service..."
1304
+ run_sudo service docker start || true
1305
+ fi
1306
+ fi
1307
+ }
1308
+
1309
+ _ensure_docker_group() {
1310
+ if [[ "$DRY_RUN" == "1" || $EUID -eq 0 || "$OS" == "macos" ]]; then
1311
+ return 0
1312
+ fi
1313
+
1314
+ # Always operate on the real (non-root) user — not whoami, which may return
1315
+ # "root" when the script was launched with "sudo bash install.sh".
1316
+ local user="${REAL_USER:-$(id -un)}"
1317
+
1318
+ # Ensure docker group exists (may be missing when Docker was installed differently)
1319
+ if ! getent group docker &>/dev/null 2>&1; then
1320
+ ui_info "Creating docker group..."
1321
+ run_sudo groupadd docker 2>/dev/null || true
1322
+ fi
1323
+
1324
+ # Use getent to check group membership rather than the shell-builtin `groups`
1325
+ # command, which reflects the login-session groups and may not include groups
1326
+ # added earlier in the same install run (especially under sudo).
1327
+ if ! getent group docker 2>/dev/null | grep -qw "${user}"; then
1328
+ ui_info "Adding ${user} to the docker group..."
1329
+ if ${SUDO} usermod -aG docker "${user}"; then
1330
+ ui_success "User ${user} added to docker group"
1331
+ else
1332
+ ui_warn "Failed to add ${user} to docker group — run manually: sudo usermod -aG docker ${user}"
1333
+ fi
1334
+ DOCKER_GROUP_JUST_ADDED=1
1335
+ else
1336
+ ui_info "User ${user} is already in the docker group"
1337
+ fi
1338
+
1339
+ # Check if docker is already accessible directly
1340
+ if docker info &>/dev/null 2>&1; then
1341
+ return 0
1342
+ fi
1343
+
1344
+ # Try activating the docker group for the current session via "sg docker"
1345
+ # This avoids requiring the user to log out and back in within the same install run
1346
+ if command -v sg &>/dev/null 2>/dev/null && sg docker -c "docker info" &>/dev/null 2>&1; then
1347
+ DOCKER_CMD_PREFIX="sg docker -c"
1348
+ ui_success "Docker group activated for this session (via 'sg docker')"
1349
+ ui_info "Future terminals will have Docker access automatically"
1350
+ return 0
1351
+ fi
1352
+
1353
+ # sg docker failed or unavailable — try ACL grant on the docker socket
1354
+ # (requires the 'acl' package; harmless if absent)
1355
+ if command -v setfacl &>/dev/null 2>&1 && [[ -S /var/run/docker.sock ]]; then
1356
+ ui_info "Trying ACL grant on /var/run/docker.sock for ${user} ..."
1357
+ if ${SUDO} setfacl -m "user:${user}:rw" /var/run/docker.sock 2>/dev/null; then
1358
+ if docker info &>/dev/null 2>&1; then
1359
+ ui_success "Docker access granted via socket ACL (current session)"
1360
+ ui_info "Future terminals will have Docker access automatically (group membership active on next login)"
1361
+ return 0
1362
+ fi
1363
+ fi
1364
+ fi
1365
+
1366
+ # Last resort — fall back to sudo docker for this session
1367
+ # This ensures OpenClaw image pull can proceed without requiring re-login
1368
+ DOCKER_USE_SUDO=1
1369
+ if [[ "${DOCKER_GROUP_JUST_ADDED:-0}" == "1" ]]; then
1370
+ ui_warn "Docker group assigned but not yet active in this shell session."
1371
+ ui_warn "Please log out and log back in (or run: newgrp docker) for group membership to take effect."
1372
+ fi
1373
+ ui_info "Using 'sudo docker' for this session; future terminals will have direct access."
1374
+ }
1375
+
1376
+ # Run a docker command, with automatic fallback priority:
1377
+ # 1. "sg docker -c" — group activated in-session (DOCKER_CMD_PREFIX set)
1378
+ # 2. sudo docker — sudo fallback when group not yet active (DOCKER_USE_SUDO=1)
1379
+ # 3. docker — direct access (group already active or running as root)
1380
+ #
1381
+ # Usage: docker_exec info
1382
+ # docker_exec pull image:tag
1383
+ # docker_exec image inspect tag
1384
+ docker_exec() {
1385
+ if [[ -n "${DOCKER_CMD_PREFIX:-}" ]]; then
1386
+ # sg docker -c expects a single string argument
1387
+ ${DOCKER_CMD_PREFIX} "docker $*"
1388
+ elif [[ "${DOCKER_USE_SUDO:-0}" == "1" ]]; then
1389
+ ${SUDO} docker "$@"
1390
+ else
1391
+ docker "$@"
1392
+ fi
1393
+ }
1394
+
1395
+ # ─── 3. Nomad ────────────────────────────────────────────────────────────────
1396
+
1397
+ install_nomad() {
1398
+ ui_stage "Nomad"
1399
+
1400
+ local local_bin="${JISHUSHELL_BIN_DIR}/nomad"
1401
+
1402
+ # In China, skip the HashiCorp apt/yum repo (GPG key download is unreliable)
1403
+ # and go straight to the binary download fallback.
1404
+ if [[ "${NETWORK_CHINA:-0}" == "1" ]]; then
1405
+ ui_info "China network — skipping HashiCorp repo, using binary download directly"
1406
+ _install_nomad_binary || return 1
1407
+ _ensure_jishushell_bin_in_path
1408
+ start_nomad
1409
+ install_nomad_systemd
1410
+ return 0
1411
+ fi
1412
+
1413
+ # ── 1. Check local user-space install ────────────────────────────────────
1414
+ if [[ -e "$local_bin" ]]; then
1415
+ if [[ ! -f "$local_bin" ]]; then
1416
+ ui_warn "Path ${local_bin} exists but is not a regular file — removing..."
1417
+ rm -rf "$local_bin" 2>/dev/null || true
1418
+ elif [[ ! -x "$local_bin" ]]; then
1419
+ ui_warn "Nomad found at ${local_bin} but is not executable — fixing permissions..."
1420
+ if ! chmod 755 "$local_bin" 2>/dev/null; then
1421
+ ui_warn "Could not fix permissions — removing and reinstalling"
1422
+ rm -f "$local_bin"
1423
+ fi
1424
+ fi
1425
+
1426
+ if [[ -x "$local_bin" ]]; then
1427
+ local current_version
1428
+ current_version="$("$local_bin" version 2>/dev/null | head -n1 | extract_semver || echo "")"
1429
+
1430
+ if [[ -z "$current_version" ]]; then
1431
+ ui_warn "Nomad at ${local_bin} is not functional (wrong arch or corrupt) — reinstalling..."
1432
+ rm -f "$local_bin"
1433
+ elif version_gte "$current_version" "$NOMAD_VERSION"; then
1434
+ ui_success "Nomad already installed: v${current_version} → ${local_bin}"
1435
+ _ensure_jishushell_bin_in_path
1436
+ return 0
1437
+ else
1438
+ ui_warn "Nomad version too old: v${current_version} (need >= v${NOMAD_VERSION}) — upgrading..."
1439
+ rm -f "$local_bin"
1440
+ fi
1441
+ fi
1442
+ fi
1443
+
1444
+ # ── 2. Check system nomad (informational only) ────────────────────────────
1445
+ if [[ ! -e "$local_bin" ]] && command -v nomad &>/dev/null; then
1446
+ local sys_ver
1447
+ sys_ver="$(nomad version 2>/dev/null | head -n1 | extract_semver || echo "?")"
1448
+ ui_info "System Nomad found (v${sys_ver}) — installing local copy to ${local_bin} for JishuShell..."
1449
+ else
1450
+ ui_info "Installing Nomad v${NOMAD_VERSION} to ${local_bin}..."
1451
+ fi
1452
+
1453
+ _do_install_nomad
1454
+ }
1455
+
1456
+ _do_install_nomad() {
1457
+ if [[ "$DRY_RUN" == "1" ]]; then
1458
+ ui_info "[dry-run] Would install Nomad v${NOMAD_VERSION} to ${JISHUSHELL_BIN_DIR}/nomad"
1459
+ ui_info "[dry-run] Would add ${JISHUSHELL_BIN_DIR} to PATH in shell configs"
1460
+ return 0
1461
+ fi
1462
+
1463
+ # Install directly to ~/.jishushell/bin — no sudo required
1464
+ _install_nomad_binary
1465
+ }
1466
+
1467
+ _install_nomad_via_repo() {
1468
+ case "$PKG_MANAGER" in
1469
+ apt)
1470
+ if ! pkg_install gpg; then
1471
+ return 1
1472
+ fi
1473
+
1474
+ local keyring="/usr/share/keyrings/hashicorp-archive-keyring.gpg"
1475
+ if [[ ! -f "$keyring" ]]; then
1476
+ ui_info "Adding HashiCorp GPG key..."
1477
+ if ! retry_net "Download HashiCorp GPG key" 3 bash -c "curl -fsSL https://apt.releases.hashicorp.com/gpg | ${SUDO} gpg --dearmor -o '$keyring' 2>/dev/null"; then
1478
+ return 1
1479
+ fi
1480
+ fi
1481
+
1482
+ local sources_file="/etc/apt/sources.list.d/hashicorp.list"
1483
+ if [[ ! -f "$sources_file" ]]; then
1484
+ ui_info "Adding HashiCorp APT repository..."
1485
+ echo "deb [signed-by=${keyring}] https://apt.releases.hashicorp.com $(lsb_release -cs 2>/dev/null || echo stable) main" | \
1486
+ ${SUDO} tee "$sources_file" >/dev/null
1487
+ fi
1488
+
1489
+ wait_for_apt_lock
1490
+ run_sudo apt-get update
1491
+ run_sudo apt-get install -y nomad
1492
+ ;;
1493
+ dnf|yum)
1494
+ local repo_file="/etc/yum.repos.d/hashicorp.repo"
1495
+ if [[ ! -f "$repo_file" ]]; then
1496
+ ui_info "Adding HashiCorp YUM repository..."
1497
+ ${SUDO} tee "$repo_file" >/dev/null <<'REPO'
1498
+ [hashicorp]
1499
+ name=HashiCorp Stable - $basearch
1500
+ baseurl=https://rpm.releases.hashicorp.com/RHEL/$releasever/$basearch/stable
1501
+ enabled=1
1502
+ gpgcheck=1
1503
+ gpgkey=https://rpm.releases.hashicorp.com/gpg
1504
+ REPO
1505
+ fi
1506
+
1507
+ run_sudo "$PKG_MANAGER" install -y nomad
1508
+ ;;
1509
+ *)
1510
+ return 1
1511
+ ;;
1512
+ esac
1513
+
1514
+ if command -v nomad &>/dev/null; then
1515
+ local ver
1516
+ ver="$(nomad version 2>/dev/null | head -n1 | extract_semver || echo "unknown")"
1517
+ ui_success "Nomad installed (via system package): v${ver}"
1518
+ return 0
1519
+ fi
1520
+
1521
+ return 1
1522
+ }
1523
+
1524
+ _install_nomad_binary() {
1525
+ local dest="${JISHUSHELL_BIN_DIR}/nomad"
1526
+
1527
+ # Ensure destination directory exists (no sudo needed — it's in $HOME)
1528
+ if ! mkdir -p "${JISHUSHELL_BIN_DIR}"; then
1529
+ ui_error "Failed to create directory: ${JISHUSHELL_BIN_DIR}"
1530
+ return 1
1531
+ fi
1532
+
1533
+ # Remove stale/non-executable leftover
1534
+ if [[ -e "$dest" && ! -x "$dest" ]]; then
1535
+ ui_warn "Removing non-executable Nomad binary at ${dest}"
1536
+ rm -f "$dest" 2>/dev/null || true
1537
+ fi
1538
+
1539
+ local platform
1540
+ platform="$(uname -s | tr '[:upper:]' '[:lower:]')"
1541
+ local download_url="https://releases.hashicorp.com/nomad/${NOMAD_VERSION}/nomad_${NOMAD_VERSION}_${platform}_${ARCH}.zip"
1542
+ local tmp_dir
1543
+ tmp_dir="$(mktemp -d)"
1544
+
1545
+ ui_info "Downloading Nomad v${NOMAD_VERSION} (${platform}/${ARCH})..."
1546
+
1547
+ if ! retry_net "Download Nomad binary" 3 curl -fsSL "$download_url" -o "${tmp_dir}/nomad.zip"; then
1548
+ ui_error "Failed to download Nomad: $download_url"
1549
+ rm -rf "$tmp_dir"
1550
+ return 1
1551
+ fi
1552
+
1553
+ local checksums_url="https://releases.hashicorp.com/nomad/${NOMAD_VERSION}/nomad_${NOMAD_VERSION}_SHA256SUMS"
1554
+ if retry_net "Download Nomad checksums" 3 curl -fsSL "$checksums_url" -o "${tmp_dir}/SHA256SUMS" 2>/dev/null; then
1555
+ local expected_hash
1556
+ expected_hash="$(grep "nomad_${NOMAD_VERSION}_${platform}_${ARCH}.zip" "${tmp_dir}/SHA256SUMS" | awk '{print $1}')"
1557
+ if [[ -n "$expected_hash" ]]; then
1558
+ local actual_hash
1559
+ if command -v sha256sum &>/dev/null; then
1560
+ actual_hash="$(sha256sum "${tmp_dir}/nomad.zip" | awk '{print $1}')"
1561
+ else
1562
+ actual_hash="$(shasum -a 256 "${tmp_dir}/nomad.zip" | awk '{print $1}')"
1563
+ fi
1564
+ if [[ "$expected_hash" != "$actual_hash" ]]; then
1565
+ ui_error "Nomad checksum mismatch — download may have been tampered with!"
1566
+ ui_error " Expected: $expected_hash"
1567
+ ui_error " Got: $actual_hash"
1568
+ rm -rf "$tmp_dir"
1569
+ return 1
1570
+ fi
1571
+ ui_info "Checksum verified ✓"
1572
+ fi
1573
+ else
1574
+ ui_error "Could not download Nomad checksum file — aborting installation for security"
1575
+ ui_error "If this is a network issue, retry. To skip verification (not recommended), use --skip-verify."
1576
+ rm -rf "$tmp_dir"
1577
+ return 1
1578
+ fi
1579
+
1580
+ if ! command -v unzip &>/dev/null; then
1581
+ ui_info "Installing unzip..."
1582
+ pkg_install unzip
1583
+ fi
1584
+
1585
+ if ! unzip -o "${tmp_dir}/nomad.zip" nomad -d "${tmp_dir}" >/dev/null 2>&1; then
1586
+ # Fallback: extract all (some zips don't support file-specific extraction)
1587
+ if ! unzip -o "${tmp_dir}/nomad.zip" -d "${tmp_dir}" >/dev/null 2>&1; then
1588
+ ui_error "Failed to extract Nomad archive"
1589
+ rm -rf "$tmp_dir"
1590
+ return 1
1591
+ fi
1592
+ fi
1593
+
1594
+ if [[ ! -f "${tmp_dir}/nomad" ]]; then
1595
+ ui_error "Nomad binary not found in downloaded archive"
1596
+ rm -rf "$tmp_dir"
1597
+ return 1
1598
+ fi
1599
+
1600
+ # Atomic install: write to temp name then rename to avoid partial reads during install
1601
+ local dest_tmp="${dest}.tmp.$$"
1602
+ if ! cp "${tmp_dir}/nomad" "$dest_tmp"; then
1603
+ ui_error "Failed to copy Nomad binary to ${JISHUSHELL_BIN_DIR}"
1604
+ rm -rf "$tmp_dir" "$dest_tmp" 2>/dev/null || true
1605
+ return 1
1606
+ fi
1607
+ chmod 755 "$dest_tmp"
1608
+ if ! mv -f "$dest_tmp" "$dest"; then
1609
+ ui_error "Failed to move Nomad binary to ${dest}"
1610
+ rm -f "$dest_tmp" 2>/dev/null || true
1611
+ rm -rf "$tmp_dir"
1612
+ return 1
1613
+ fi
1614
+ rm -rf "$tmp_dir"
1615
+
1616
+ # Verify the installed binary is executable and actually runs
1617
+ if [[ ! -x "$dest" ]]; then
1618
+ ui_error "Nomad binary at ${dest} is not executable after install"
1619
+ return 1
1620
+ fi
1621
+
1622
+ local installed_version
1623
+ installed_version="$("$dest" version 2>/dev/null | head -n1 | extract_semver || echo "")"
1624
+ if [[ -z "$installed_version" ]]; then
1625
+ ui_error "Nomad binary at ${dest} does not run — possibly wrong architecture (${ARCH})"
1626
+ rm -f "$dest" 2>/dev/null || true
1627
+ return 1
1628
+ fi
1629
+
1630
+ _ensure_jishushell_bin_in_path
1631
+ ui_success "Nomad installed: v${installed_version} → ${dest}"
1632
+ }
1633
+
1634
+ # Add ~/.jishushell/bin to PATH in shell startup files and current session
1635
+ _ensure_jishushell_bin_in_path() {
1636
+ local bin_dir="${JISHUSHELL_BIN_DIR}"
1637
+ local marker="# jishushell-bin-path"
1638
+ local init_line="export PATH=\"${bin_dir}:\$PATH\""
1639
+
1640
+ # Export for the current running shell immediately
1641
+ export PATH="${bin_dir}:${PATH}"
1642
+
1643
+ if [[ "$DRY_RUN" == "1" ]]; then
1644
+ ui_info "[dry-run] Would add ${bin_dir} to PATH in shell startup files"
1645
+ return 0
1646
+ fi
1647
+
1648
+ local rc_files=("$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile" "$HOME/.zshrc")
1649
+ local added=0
1650
+ for rc in "${rc_files[@]}"; do
1651
+ if [[ -f "$rc" ]] && ! grep -qF "$marker" "$rc" 2>/dev/null; then
1652
+ printf '\n%s\n%s\n' "$marker" "$init_line" >> "$rc"
1653
+ ui_info "Added ${bin_dir} to PATH in ${rc}"
1654
+ added=1
1655
+ fi
1656
+ done
1657
+
1658
+ if [[ $added -eq 0 ]]; then
1659
+ ui_info "JishuShell bin PATH already configured"
1660
+ fi
1661
+ ui_info "Tip: run 'source ~/.bashrc' or open a new terminal to activate PATH"
1662
+ }
1663
+
1664
+ # Check if a TCP port is in LISTEN state (cross-platform: Linux ss, macOS lsof)
1665
+ _port_is_listening() {
1666
+ local port="$1"
1667
+ if command -v ss &>/dev/null; then
1668
+ ss -tlnp 2>/dev/null | grep -q ":${port} "
1669
+ elif command -v lsof &>/dev/null; then
1670
+ lsof -iTCP:"${port}" -sTCP:LISTEN &>/dev/null 2>&1
1671
+ else
1672
+ nc -z 127.0.0.1 "${port}" &>/dev/null 2>&1
1673
+ fi
1674
+ }
1675
+
1676
+ # Write nomad.hcl if it does not already exist.
1677
+ # Safe to call multiple times — never overwrites an existing config.
1678
+ _ensure_nomad_hcl() {
1679
+ local nomad_config_dir="${JISHUSHELL_HOME}/nomad"
1680
+ local nomad_data_dir="${JISHUSHELL_HOME}/nomad/data"
1681
+ local nomad_alloc_dir="${JISHUSHELL_HOME}/nomad/data/alloc"
1682
+ local config_file="${nomad_config_dir}/nomad.hcl"
1683
+
1684
+ [[ -f "$config_file" ]] && return 0
1685
+
1686
+ mkdir -p "$nomad_config_dir" "$nomad_data_dir" "$nomad_alloc_dir"
1687
+ # Dirs are created by the current user — no sudo needed
1688
+ chown -R "${REAL_USER}:${REAL_GID:-${REAL_USER}}" "${JISHUSHELL_HOME}" 2>/dev/null || true
1689
+
1690
+ cat > "$config_file" << NOMAD_HCL
1691
+ data_dir = "${nomad_data_dir}"
1692
+
1693
+ bind_addr = "127.0.0.1"
1694
+
1695
+ leave_on_terminate = false
1696
+
1697
+ advertise {
1698
+ http = "127.0.0.1"
1699
+ rpc = "127.0.0.1"
1700
+ serf = "127.0.0.1"
1701
+ }
1702
+
1703
+ server {
1704
+ enabled = true
1705
+ bootstrap_expect = 1
1706
+ }
1707
+
1708
+ client {
1709
+ enabled = true
1710
+ servers = ["127.0.0.1:4647"]
1711
+ alloc_dir = "${nomad_alloc_dir}"
1712
+
1713
+ drain_on_shutdown {
1714
+ deadline = "30s"
1715
+ force = true
1716
+ ignore_system_jobs = true
1717
+ }
1718
+ }
1719
+
1720
+ plugin "docker" {
1721
+ config {
1722
+ disable_log_collection = true
1723
+ volumes {
1724
+ enabled = true
1725
+ }
1726
+ }
1727
+ }
1728
+
1729
+ acl {
1730
+ enabled = true
1731
+ }
1732
+ NOMAD_HCL
1733
+ }
1734
+
1735
+ # Start Nomad agent (matches setup-manager.ts startNomad())
1736
+ start_nomad() {
1737
+ local nomad_bin="${JISHUSHELL_BIN_DIR}/nomad"
1738
+ local nomad_config_dir="${JISHUSHELL_HOME}/nomad"
1739
+ local config_file="${nomad_config_dir}/nomad.hcl"
1740
+
1741
+ # Check if already running (port 4646)
1742
+ if _port_is_listening 4646; then
1743
+ ui_success "Nomad is already running on port 4646"
1744
+ return 0
1745
+ fi
1746
+
1747
+ if [[ "$DRY_RUN" == "1" ]]; then
1748
+ ui_info "[dry-run] Would write ${config_file} and start nomad agent"
1749
+ return 0
1750
+ fi
1751
+
1752
+ # On Linux with systemd: the single path for starting Nomad.
1753
+ # First kill any stale non-systemd Nomad processes that may hold the port.
1754
+ if command -v systemctl &>/dev/null && systemctl is-enabled nomad &>/dev/null 2>&1; then
1755
+ local stale_pids
1756
+ stale_pids="$(pgrep -f 'nomad agent' 2>/dev/null || true)"
1757
+ if [[ -n "$stale_pids" ]]; then
1758
+ local systemd_pid
1759
+ systemd_pid="$(systemctl show nomad --property=MainPID --value 2>/dev/null || echo "")"
1760
+ local pid
1761
+ for pid in $stale_pids; do
1762
+ if [[ "$pid" != "$systemd_pid" ]]; then
1763
+ ui_info "Killing stale Nomad process (PID ${pid}) before systemd start..."
1764
+ kill "$pid" 2>/dev/null || true
1765
+ fi
1766
+ done
1767
+ sleep 1
1768
+ fi
1769
+
1770
+ ui_info "Starting Nomad via systemd..."
1771
+ ${SUDO} systemctl start nomad 2>/dev/null || true
1772
+ local i
1773
+ for i in $(seq 1 30); do
1774
+ sleep 1
1775
+ if _port_is_listening 4646; then
1776
+ ui_success "Nomad started (systemd)"
1777
+ return 0
1778
+ fi
1779
+ done
1780
+ ui_warn "Nomad did not start within 30s — port 4646 not listening"
1781
+ ui_info "Check: sudo journalctl -u nomad -n 30"
1782
+ return 1
1783
+ fi
1784
+
1785
+ # On macOS with launchd: plist is already loaded with RunAtLoad=true, so launchd
1786
+ # started Nomad. Just wait for the port — do NOT spawn a competing nohup process.
1787
+ local plist_path="${HOME}/Library/LaunchAgents/com.jishushell.nomad.plist"
1788
+ if [[ "$OS" == "macos" ]] && [[ -f "$plist_path" ]]; then
1789
+ ui_info "Waiting for Nomad (launchd)..."
1790
+ local i
1791
+ for i in $(seq 1 30); do
1792
+ sleep 1
1793
+ if _port_is_listening 4646; then
1794
+ ui_success "Nomad started"
1795
+ return 0
1796
+ fi
1797
+ done
1798
+ ui_warn "Nomad did not start within 30s — port 4646 not listening"
1799
+ ui_info "Check log: ${JISHUSHELL_HOME}/nomad/nomad.log"
1800
+ return 1
1801
+ fi
1802
+
1803
+ # Fallback: non-systemd environments (minimal containers without launchd).
1804
+ _ensure_nomad_hcl
1805
+
1806
+ ui_info "Starting Nomad agent..."
1807
+ local log_path="${nomad_config_dir}/nomad.log"
1808
+ # Run as the current (non-root) user — Nomad only binds to 127.0.0.1:4646/4647/4648
1809
+ # (non-privileged ports), and all data dirs live under ~/.jishushell/, so root is not needed.
1810
+ nohup "${nomad_bin}" agent -config="${config_file}" > "${log_path}" 2>&1 &
1811
+
1812
+ local i
1813
+ for i in $(seq 1 30); do
1814
+ sleep 1
1815
+ if _port_is_listening 4646; then
1816
+ ui_success "Nomad started"
1817
+ return 0
1818
+ fi
1819
+ done
1820
+
1821
+ ui_warn "Nomad did not start within 30s — port 4646 not listening"
1822
+ ui_info "Check log: ${log_path}"
1823
+ return 1
1824
+ }
1825
+
1826
+ # Install Nomad as a system service (systemd on Linux, launchd on macOS)
1827
+ install_nomad_systemd() {
1828
+ local nomad_bin="${JISHUSHELL_BIN_DIR}/nomad"
1829
+ local nomad_config_dir="${JISHUSHELL_HOME}/nomad"
1830
+ local config_file="${nomad_config_dir}/nomad.hcl"
1831
+
1832
+ if [[ "$OS" == "macos" ]]; then
1833
+ _install_nomad_launchd
1834
+ return $?
1835
+ fi
1836
+
1837
+ if [[ "$(uname -s)" != "Linux" ]]; then
1838
+ return 0
1839
+ fi
1840
+
1841
+ if [[ "$DRY_RUN" == "1" ]]; then
1842
+ ui_info "[dry-run] Would install /etc/systemd/system/nomad.service"
1843
+ return 0
1844
+ fi
1845
+
1846
+ _ensure_nomad_hcl
1847
+
1848
+ local service_content="[Unit]
1849
+ Description=Nomad Agent
1850
+ After=network-online.target docker.service
1851
+ Wants=network-online.target
1852
+
1853
+ [Service]
1854
+ User=${REAL_USER}
1855
+ SupplementaryGroups=docker
1856
+ Type=simple
1857
+ EnvironmentFile=-/etc/jishushell/nomad.env
1858
+ ExecStart=${nomad_bin} agent -config=${config_file}
1859
+ Restart=on-failure
1860
+ RestartSec=3
1861
+ ProtectSystem=full
1862
+ PrivateTmp=true
1863
+ NoNewPrivileges=true
1864
+
1865
+ [Install]
1866
+ WantedBy=multi-user.target"
1867
+
1868
+ # Only update the service file if it changed (avoids unnecessary restarts)
1869
+ local svc_path="/etc/systemd/system/nomad.service"
1870
+ local need_reload=0
1871
+ if [[ ! -f "$svc_path" ]] || ! echo "$service_content" | diff -q - "$svc_path" &>/dev/null; then
1872
+ ${SUDO} mkdir -p /etc/jishushell
1873
+ echo "$service_content" > /tmp/nomad.service.$$
1874
+ ${SUDO} mv /tmp/nomad.service.$$ "$svc_path"
1875
+ need_reload=1
1876
+ fi
1877
+
1878
+ # Ensure Nomad data dirs are owned by the real user before the service starts
1879
+ chown -R "${REAL_USER}:${REAL_GID:-${REAL_USER}}" "${JISHUSHELL_HOME}" 2>/dev/null || true
1880
+
1881
+ if [[ $need_reload -eq 1 ]]; then
1882
+ ${SUDO} systemctl daemon-reload
1883
+ fi
1884
+
1885
+ # Enable but do NOT start here — start_nomad() handles startup + waiting.
1886
+ # This avoids double-waiting (install_nomad_systemd 15s + start_nomad 30s).
1887
+ if ! systemctl is-enabled nomad &>/dev/null 2>&1; then
1888
+ ${SUDO} systemctl enable nomad 2>/dev/null || \
1889
+ ui_warn "Could not enable nomad systemd service — Nomad may not auto-start on reboot"
1890
+ fi
1891
+ }
1892
+
1893
+ _install_nomad_launchd() {
1894
+ local nomad_bin="${JISHUSHELL_BIN_DIR}/nomad"
1895
+ local nomad_config_dir="${JISHUSHELL_HOME}/nomad"
1896
+ local config_file="${nomad_config_dir}/nomad.hcl"
1897
+ local plist_label="com.jishushell.nomad"
1898
+ local plist_path="${HOME}/Library/LaunchAgents/${plist_label}.plist"
1899
+ local log_path="${nomad_config_dir}/nomad.log"
1900
+
1901
+ if [[ "$DRY_RUN" == "1" ]]; then
1902
+ ui_info "[dry-run] Would install launchd agent: ${plist_path}"
1903
+ return 0
1904
+ fi
1905
+
1906
+ mkdir -p "${HOME}/Library/LaunchAgents"
1907
+
1908
+ local docker_sock="${HOME}/.docker/run/docker.sock"
1909
+ if [[ ! -S "$docker_sock" ]]; then
1910
+ docker_sock="/var/run/docker.sock"
1911
+ fi
1912
+
1913
+ cat > "$plist_path" << PLIST
1914
+ <?xml version="1.0" encoding="UTF-8"?>
1915
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1916
+ <plist version="1.0">
1917
+ <dict>
1918
+ <key>Label</key>
1919
+ <string>${plist_label}</string>
1920
+ <key>ProgramArguments</key>
1921
+ <array>
1922
+ <string>${nomad_bin}</string>
1923
+ <string>agent</string>
1924
+ <string>-config=${config_file}</string>
1925
+ </array>
1926
+ <key>EnvironmentVariables</key>
1927
+ <dict>
1928
+ <key>DOCKER_HOST</key>
1929
+ <string>unix://${docker_sock}</string>
1930
+ <key>PATH</key>
1931
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
1932
+ </dict>
1933
+ <key>RunAtLoad</key>
1934
+ <true/>
1935
+ <key>KeepAlive</key>
1936
+ <true/>
1937
+ <key>StandardOutPath</key>
1938
+ <string>${log_path}</string>
1939
+ <key>StandardErrorPath</key>
1940
+ <string>${log_path}</string>
1941
+ </dict>
1942
+ </plist>
1943
+ PLIST
1944
+
1945
+ _ensure_nomad_hcl
1946
+
1947
+ launchctl unload "$plist_path" 2>/dev/null || true
1948
+ if launchctl load -w "$plist_path" 2>/dev/null; then
1949
+ ui_success "Nomad launchd agent installed and started"
1950
+ else
1951
+ ui_warn "Could not load nomad launchd agent"
1952
+ fi
1953
+ }
1954
+
1955
+ # ─── 4. OpenClaw (docker pull official image) ─────────────────────────────────
1956
+ # For Docker mode: pull the official OpenClaw image from ghcr.io.
1957
+ # For non-Docker modes (process manager / raw_exec): npm install as before.
1958
+
1959
+ _save_openclaw_image_to_panel() {
1960
+ local image="$1"
1961
+ local panel_file="${JISHUSHELL_HOME}/panel.json"
1962
+ node -e "
1963
+ const fs = require('fs');
1964
+ const p = '${panel_file}';
1965
+ let cfg = {};
1966
+ try { cfg = JSON.parse(fs.readFileSync(p, 'utf8')); } catch {}
1967
+ cfg.openclaw_image = '${image}';
1968
+ fs.mkdirSync(require('path').dirname(p), { recursive: true });
1969
+ fs.writeFileSync(p, JSON.stringify(cfg, null, 2));
1970
+ " 2>/dev/null || true
1971
+ }
1972
+
1973
+ _read_openclaw_image_from_panel() {
1974
+ local panel_file="${JISHUSHELL_HOME}/panel.json"
1975
+ node -e "
1976
+ const fs = require('fs');
1977
+ const p = '${panel_file}';
1978
+ try {
1979
+ const cfg = JSON.parse(fs.readFileSync(p, 'utf8'));
1980
+ if (typeof cfg.openclaw_image === 'string' && cfg.openclaw_image.trim()) {
1981
+ process.stdout.write(cfg.openclaw_image.trim());
1982
+ }
1983
+ } catch {}
1984
+ " 2>/dev/null || true
1985
+ }
1986
+
1987
+ # Install OpenClaw npm package on the host (for process manager / raw_exec modes).
1988
+ # Skipped when using official Docker image.
1989
+ _install_openclaw_npm() {
1990
+ local pkg_dir="${JISHUSHELL_HOME}/packages/openclaw"
1991
+
1992
+ local local_bin="${pkg_dir}/bin/openclaw"
1993
+ local local_pkg="${pkg_dir}/lib/node_modules/openclaw/package.json"
1994
+ local _need_install=0
1995
+ if [[ -f "$local_bin" ]]; then
1996
+ local current_ver
1997
+ current_ver="$(node -p "require('${local_pkg}').version" 2>/dev/null || echo "")"
1998
+ if [[ "${OPENCLAW_NPM_VERSION}" != "latest" && "${current_ver}" != "${OPENCLAW_NPM_VERSION}" ]]; then
1999
+ ui_info "OpenClaw installed version (v${current_ver:-unknown}) differs from requested (${OPENCLAW_NPM_VERSION}) — reinstalling..."
2000
+ _need_install=1
2001
+ else
2002
+ ui_success "OpenClaw npm package already installed: v${current_ver:-unknown}"
2003
+ fi
2004
+ else
2005
+ _need_install=1
2006
+ fi
2007
+ if [[ "$_need_install" == "1" ]]; then
2008
+ if ! command -v npm &>/dev/null; then
2009
+ ui_error "npm not found — install Node.js first"
2010
+ return 1
2011
+ fi
2012
+ ui_info "Installing OpenClaw npm package (openclaw@${OPENCLAW_NPM_VERSION})..."
2013
+ local _oc_registry=""
2014
+ if [[ "${NETWORK_CHINA:-0}" == "1" ]]; then
2015
+ _oc_registry="--registry https://registry.npmmirror.com"
2016
+ ui_info "China network — using Alibaba npm registry (npmmirror.com)"
2017
+ fi
2018
+ mkdir -p "$pkg_dir"
2019
+ log_detail ""
2020
+ log_detail "[$(date '+%H:%M:%S')] npm install -g --prefix ${pkg_dir} openclaw@${OPENCLAW_NPM_VERSION}${_oc_registry:+ ${_oc_registry}}"
2021
+ # shellcheck disable=SC2086
2022
+ if ! retry_net "npm install -g --prefix openclaw@${OPENCLAW_NPM_VERSION}" 3 \
2023
+ log_cmd env -u npm_config_global -u npm_config_prefix -u npm_config_location \
2024
+ npm install -g --prefix "$pkg_dir" ${_oc_registry} "openclaw@${OPENCLAW_NPM_VERSION}"; then
2025
+ ui_error "Failed to install OpenClaw npm package"
2026
+ return 1
2027
+ fi
2028
+ fi
2029
+ }
2030
+
2031
+ install_openclaw() {
2032
+ ui_stage "OpenClaw"
2033
+
2034
+ if [[ "${SKIP_OPENCLAW}" == "1" ]]; then
2035
+ ui_info "Skipped (--skip-openclaw / default)"
2036
+ return 0
2037
+ fi
2038
+
2039
+ if ! command -v docker &>/dev/null; then
2040
+ ui_error "Docker is not installed — cannot build OpenClaw image"
2041
+ return 1
2042
+ fi
2043
+
2044
+ if [[ "$DRY_RUN" == "1" ]]; then
2045
+ ui_info "[dry-run] Would: npm install -g --prefix openclaw@${OPENCLAW_NPM_VERSION}"
2046
+ ui_info "[dry-run] Would: docker build -t jishushell-openclaw:<version> (npm package + Python)"
2047
+ return 0
2048
+ fi
2049
+
2050
+ # ── Step 1: Install OpenClaw npm package (used as docker build context) ──
2051
+ _install_openclaw_npm || return 1
2052
+
2053
+ # Resolve versioned tag from installed package (e.g. jishushell-openclaw:2026.3.31)
2054
+ local pkg_dir="${JISHUSHELL_HOME}/packages/openclaw"
2055
+ local oc_ver
2056
+ oc_ver="$(node -p "require('${pkg_dir}/lib/node_modules/openclaw/package.json').version" 2>/dev/null || echo "")"
2057
+ local docker_tag="${OPENCLAW_DOCKER_TAG}"
2058
+ if [[ -n "$oc_ver" ]]; then
2059
+ docker_tag="jishushell-openclaw:${oc_ver}"
2060
+ fi
2061
+ local configured_tag=""
2062
+
2063
+ # ── Step 2: Ensure Docker daemon is accessible ────────────────────────────
2064
+ if ! docker_exec info &>/dev/null 2>&1; then
2065
+ if command -v sg &>/dev/null 2>/dev/null && sg docker -c "docker info" &>/dev/null 2>&1; then
2066
+ DOCKER_CMD_PREFIX="sg docker -c"
2067
+ ui_info "Docker group activated via 'sg docker'"
2068
+ elif ${SUDO} docker info &>/dev/null 2>&1; then
2069
+ DOCKER_USE_SUDO=1
2070
+ ui_info "Using 'sudo docker' (docker group not yet active in this shell)"
2071
+ else
2072
+ ui_warn "Docker daemon is not reachable"
2073
+ if [[ "$OS" == "macos" ]]; then
2074
+ ui_warn "Make sure Docker Desktop is running"
2075
+ else
2076
+ ui_warn "Ensure Docker is running: sudo systemctl start docker"
2077
+ fi
2078
+ return 1
2079
+ fi
2080
+ fi
2081
+
2082
+ # ── Step 3: Reuse the currently configured image when it already exists
2083
+ # locally. This keeps repeated installs idempotent and avoids rebuilding
2084
+ # when panel.json already points at a valid pinned image.
2085
+ configured_tag="$(_read_openclaw_image_from_panel)"
2086
+ if [[ -n "${configured_tag}" ]] && docker_exec image inspect "${configured_tag}" &>/dev/null 2>&1; then
2087
+ OPENCLAW_IMAGE="${configured_tag}"
2088
+ ui_success "Docker image ${configured_tag} already exists — reusing configured image"
2089
+ return 0
2090
+ fi
2091
+
2092
+ # ── Step 4: Skip if the requested build tag already exists ───────────────
2093
+ if docker_exec image inspect "${docker_tag}" &>/dev/null 2>&1; then
2094
+ OPENCLAW_IMAGE="${docker_tag}"
2095
+ _save_openclaw_image_to_panel "${docker_tag}"
2096
+ ui_success "Docker image ${docker_tag} already exists — skipping"
2097
+ return 0
2098
+ fi
2099
+
2100
+ # ── Step 5: Build Docker image (npm package + Python) ────────────────────
2101
+ local pkg_dir="${JISHUSHELL_HOME}/packages/openclaw"
2102
+
2103
+ # Write Dockerfile into the npm package directory (build context)
2104
+ # When NETWORK_CHINA=1, inject Alibaba apt mirror before apt-get update
2105
+ if [[ "${NETWORK_CHINA:-0}" == "1" ]]; then
2106
+ ui_info "China network — injecting Alibaba apt mirror into Dockerfile"
2107
+ cat > "${pkg_dir}/Dockerfile" << 'DOCKERFILE'
2108
+ FROM node:22-slim
2109
+ USER root
2110
+ RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g; s/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
2111
+ sed -i 's/deb.debian.org/mirrors.aliyun.com/g; s/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || true
2112
+ RUN apt-get update && apt-get install -y --no-install-recommends \
2113
+ procps hostname curl git lsof openssl \
2114
+ python3 python3-pip python3-venv python3-dev && \
2115
+ ln -sf /usr/bin/python3 /usr/local/bin/python && \
2116
+ rm -rf /var/lib/apt/lists/*
2117
+ WORKDIR /app
2118
+ COPY --chown=node:node lib/node_modules/ ./node_modules/
2119
+ RUN ln -sf /app/node_modules/openclaw/openclaw.mjs /app/openclaw.mjs && \
2120
+ ln -sf /app/node_modules/openclaw/openclaw.mjs /usr/local/bin/openclaw && \
2121
+ cp /app/node_modules/openclaw/package.json /app/package.json 2>/dev/null || true
2122
+ USER node
2123
+ CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]
2124
+ DOCKERFILE
2125
+ else
2126
+ cat > "${pkg_dir}/Dockerfile" << 'DOCKERFILE'
2127
+ FROM node:22-slim
2128
+ USER root
2129
+ RUN apt-get update && apt-get install -y --no-install-recommends \
2130
+ procps hostname curl git lsof openssl \
2131
+ python3 python3-pip python3-venv python3-dev && \
2132
+ ln -sf /usr/bin/python3 /usr/local/bin/python && \
2133
+ rm -rf /var/lib/apt/lists/*
2134
+ WORKDIR /app
2135
+ COPY --chown=node:node lib/node_modules/ ./node_modules/
2136
+ RUN ln -sf /app/node_modules/openclaw/openclaw.mjs /app/openclaw.mjs && \
2137
+ ln -sf /app/node_modules/openclaw/openclaw.mjs /usr/local/bin/openclaw && \
2138
+ cp /app/node_modules/openclaw/package.json /app/package.json 2>/dev/null || true
2139
+ USER node
2140
+ CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]
2141
+ DOCKERFILE
2142
+ fi
2143
+
2144
+ ui_info "Building Docker image: ${docker_tag} (npm package + Python)..."
2145
+ log_detail ""
2146
+ log_detail "[$(date '+%H:%M:%S')] docker build -t ${docker_tag} ${pkg_dir}"
2147
+ if log_cmd docker_exec build --network=host -t "${docker_tag}" "${pkg_dir}"; then
2148
+ OPENCLAW_IMAGE="${docker_tag}"
2149
+ _save_openclaw_image_to_panel "${docker_tag}"
2150
+ local _local_tag="jishushell-openclaw:local"
2151
+ if [[ "${docker_tag}" != "${_local_tag}" ]]; then
2152
+ docker_exec tag "${docker_tag}" "${_local_tag}" 2>/dev/null || true
2153
+ fi
2154
+ rm -f "${pkg_dir}/Dockerfile"
2155
+ ui_success "OpenClaw Docker image built: ${docker_tag} (with Python)"
2156
+ else
2157
+ rm -f "${pkg_dir}/Dockerfile"
2158
+ ui_error "Failed to build OpenClaw Docker image"
2159
+ return 1
2160
+ fi
2161
+ }
2162
+
2163
+ _prompt_openclaw_skip() {
2164
+ if [[ ! -t 0 || ! -t 1 ]]; then
2165
+ SKIP_OPENCLAW=0
2166
+ ui_info "Non-interactive shell detected — OpenClaw will be installed by default"
2167
+ return 0
2168
+ fi
2169
+
2170
+ local answer
2171
+ echo ""
2172
+ echo -e "${ACCENT}${BOLD}OpenClaw${NC}"
2173
+ echo -e "${INFO} Installs the OpenClaw npm package and builds a Docker image with Python.${NC}"
2174
+ echo -e "${INFO} Requires Docker to be running; the build may take a few minutes.${NC}"
2175
+ echo ""
2176
+ local answer answer_lc
2177
+ read -r -p "$(echo -e "${MUTED} Install OpenClaw and build Docker image? [Y/n]: ${NC}")" answer </dev/tty || answer="y"
2178
+ answer_lc="$(echo "$answer" | tr '[:upper:]' '[:lower:]')"
2179
+ case "$answer_lc" in
2180
+ n|no) SKIP_OPENCLAW=1 ;;
2181
+ *) SKIP_OPENCLAW=0 ;;
2182
+ esac
2183
+ }
2184
+
2185
+ # show_install_plan [--with-jishushell]
2186
+ show_install_plan() {
2187
+ local with_jishushell=0
2188
+ [[ "${1:-}" == "--with-jishushell" ]] && with_jishushell=1
2189
+
2190
+ detect_network
2191
+
2192
+ echo ""
2193
+ echo -e "${ACCENT}${BOLD}Install Plan${NC}"
2194
+ echo -e "${MUTED}────────────────────────────────${NC}"
2195
+ ui_kv "OS" "$OS_NAME"
2196
+ ui_kv "Package manager" "$PKG_MANAGER"
2197
+ ui_kv "Architecture" "$ARCH"
2198
+ ui_kv "Network" "$(if [[ $NETWORK_CHINA -eq 1 ]]; then echo 'China (Alibaba mirrors)'; else echo 'Global'; fi)"
2199
+ echo ""
2200
+ ui_kv "Node.js" "$(if [[ $SKIP_NODE -eq 1 ]]; then echo 'skip'; else echo "v${NODE_VERSION} via nvm v${NVM_VERSION}"; fi)"
2201
+ ui_kv "Docker" "$(if [[ $SKIP_DOCKER -eq 1 ]]; then echo 'skip'; else echo 'latest stable'; fi)"
2202
+ ui_kv "Nomad" "$(if [[ $SKIP_NOMAD -eq 1 ]]; then echo 'skip'; else echo "v${NOMAD_VERSION}"; fi)"
2203
+ ui_kv "OpenClaw" "$(if [[ \"${SKIP_OPENCLAW}\" == \"1\" ]]; then echo 'skip'; else echo \"openclaw@${OPENCLAW_NPM_VERSION} + docker build ${OPENCLAW_DOCKER_TAG}\"; fi)"
2204
+ if [[ $with_jishushell -eq 1 ]]; then
2205
+ local _plan_jishu
2206
+ if [[ $SKIP_JISHUSHELL -eq 1 ]]; then
2207
+ _plan_jishu="skip"
2208
+ else
2209
+ local _plan_tgz=""
2210
+ for _c in "${JISHU_SCRIPT_DIR}"/jishushell-*.tgz; do
2211
+ [[ -f "$_c" ]] && { _plan_tgz="$(basename "$_c")"; break; }
2212
+ done
2213
+ _plan_jishu="${_plan_tgz:+npm install -g ${_plan_tgz} (local)}${_plan_tgz:-npm install -g jishushell}"
2214
+ fi
2215
+ ui_kv "JishuShell" "$_plan_jishu"
2216
+ ui_kv "JishuShell service" "$(if [[ $SKIP_JISHUSHELL_SERVICE -eq 1 ]]; then echo 'skip'; else echo 'register autostart'; fi)"
2217
+ fi
2218
+
2219
+ if [[ "$DRY_RUN" == "1" ]]; then
2220
+ echo ""
2221
+ ui_warn "Dry-run mode: no changes will be made to this system"
2222
+ fi
2223
+ echo ""
2224
+ }
2225
+
2226
+ # show_summary [--with-jishushell]
2227
+ show_summary() {
2228
+ local with_jishushell=0
2229
+ [[ "${1:-}" == "--with-jishushell" ]] && with_jishushell=1
2230
+
2231
+ echo ""
2232
+ echo -e "${ACCENT}${BOLD}Summary${NC}"
2233
+ echo -e "${MUTED}────────────────────────────────${NC}"
2234
+
2235
+ local all_ok=1
2236
+
2237
+ if [[ $SKIP_NODE -eq 0 ]]; then
2238
+ if command -v node &>/dev/null; then
2239
+ ui_kv "Node.js" "✓ $(node --version 2>/dev/null)"
2240
+ else
2241
+ ui_kv "Node.js" "✗ not installed"
2242
+ all_ok=0
2243
+ fi
2244
+ fi
2245
+
2246
+ if [[ $SKIP_DOCKER -eq 0 ]]; then
2247
+ if command -v docker &>/dev/null; then
2248
+ local _docker_ver
2249
+ _docker_ver="$(docker version --format '{{.Server.Version}}' 2>/dev/null | extract_semver || docker --version 2>/dev/null | extract_semver || echo 'installed')"
2250
+ ui_kv "Docker" "✓ v${_docker_ver}"
2251
+ else
2252
+ ui_kv "Docker" "✗ not installed"
2253
+ all_ok=0
2254
+ fi
2255
+ fi
2256
+
2257
+ if [[ $SKIP_NOMAD -eq 0 ]]; then
2258
+ local _nomad_bin="${JISHUSHELL_BIN_DIR}/nomad"
2259
+ if [[ -x "$_nomad_bin" ]]; then
2260
+ local _nomad_ver
2261
+ _nomad_ver="$("$_nomad_bin" version 2>/dev/null | head -n1 | extract_semver || echo 'installed')"
2262
+ ui_kv "Nomad" "✓ v${_nomad_ver} (${_nomad_bin})"
2263
+ elif command -v nomad &>/dev/null; then
2264
+ local _nomad_ver
2265
+ _nomad_ver="$(nomad version 2>/dev/null | head -n1 | extract_semver || echo 'installed')"
2266
+ ui_kv "Nomad" "✓ v${_nomad_ver} (system: $(command -v nomad))"
2267
+ else
2268
+ ui_kv "Nomad" "✗ not installed"
2269
+ all_ok=0
2270
+ fi
2271
+ fi
2272
+
2273
+ if [[ "${SKIP_OPENCLAW}" != "1" ]]; then
2274
+ if [[ -n "${OPENCLAW_IMAGE}" ]] && docker_exec image inspect "${OPENCLAW_IMAGE}" &>/dev/null 2>&1; then
2275
+ ui_kv "OpenClaw" "✓ ${OPENCLAW_IMAGE}"
2276
+ elif [[ "$DRY_RUN" == "1" ]]; then
2277
+ ui_kv "OpenClaw" "- dry-run"
2278
+ else
2279
+ ui_kv "OpenClaw" "✗ image not found locally"
2280
+ all_ok=0
2281
+ fi
2282
+ fi
2283
+
2284
+ if [[ $with_jishushell -eq 1 && $SKIP_JISHUSHELL -eq 0 ]]; then
2285
+ local _wrapper="${JISHUSHELL_BIN_DIR}/jishushell-panel-start"
2286
+ if [[ -x "$_wrapper" ]]; then
2287
+ ui_kv "JishuShell" "✓ ${_wrapper}"
2288
+ elif command -v jishushell &>/dev/null; then
2289
+ ui_kv "JishuShell" "✓ $(command -v jishushell)"
2290
+ else
2291
+ ui_kv "JishuShell" "✗ not found"
2292
+ all_ok=0
2293
+ fi
2294
+ fi
2295
+
2296
+ echo ""
2297
+ if [[ $all_ok -eq 1 ]]; then
2298
+ echo -e "${SUCCESS}${BOLD}All components installed successfully!${NC}"
2299
+ else
2300
+ echo -e "${WARN}${BOLD}One or more components failed — review the log above.${NC}"
2301
+ fi
2302
+ # Remind the user to re-login if docker group was added and sg could not
2303
+ # activate it in-session (DOCKER_CMD_PREFIX would be set if sg succeeded).
2304
+ if [[ "${DOCKER_GROUP_JUST_ADDED:-0}" == "1" && -z "${DOCKER_CMD_PREFIX:-}" ]]; then
2305
+ echo ""
2306
+ echo -e "${WARN}${BOLD}Action required — Docker group membership:${NC}"
2307
+ echo -e "${WARN} User '${REAL_USER:-$(id -un)}' was added to the 'docker' group, but the"
2308
+ echo -e "${WARN} change is not active in the current shell session.${NC}"
2309
+ echo -e "${WARN} Please log out and log back in, or run:${NC}"
2310
+ echo -e "${ACCENT} newgrp docker${NC}"
2311
+ echo -e "${WARN} to use Docker without sudo.${NC}"
2312
+ fi
2313
+ if [[ -n "${JISHU_LOG_FILE:-}" ]]; then
2314
+ echo ""
2315
+ ui_kv "Log file" "${JISHU_LOG_FILE}"
2316
+ fi
2317
+ echo ""
2318
+ if [[ $all_ok -eq 1 && $with_jishushell -eq 1 && $SKIP_JISHUSHELL -eq 0 ]]; then
2319
+ # Detect the primary non-loopback LAN IP address.
2320
+ # Use || true on every substitution to prevent set -e from firing when
2321
+ # the command does not exist on this OS (e.g. "ip" is Linux-only).
2322
+ local _local_ip=""
2323
+ if [[ "$(uname -s)" == "Darwin" ]]; then
2324
+ local _iface
2325
+ _iface="$(route -n get 1.1.1.1 2>/dev/null | awk '/interface:/{print $2}')" || true
2326
+ if [[ -n "$_iface" ]]; then
2327
+ _local_ip="$(ipconfig getifaddr "$_iface" 2>/dev/null)" || true
2328
+ fi
2329
+ if [[ -z "$_local_ip" ]]; then
2330
+ _local_ip="$(ipconfig getifaddr en0 2>/dev/null)" || true
2331
+ fi
2332
+ if [[ -z "$_local_ip" ]]; then
2333
+ _local_ip="$(ipconfig getifaddr en1 2>/dev/null)" || true
2334
+ fi
2335
+ else
2336
+ _local_ip="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") {print $(i+1); exit}}')" || true
2337
+ if [[ -z "$_local_ip" ]]; then
2338
+ _local_ip="$(hostname -I 2>/dev/null | awk '{print $1}')" || true
2339
+ fi
2340
+ fi
2341
+
2342
+ echo -e "${SUCCESS}${BOLD} ╔══════════════════════════════════════════════════════╗${NC}"
2343
+ echo -e "${SUCCESS}${BOLD} ║ Installation complete! ║${NC}"
2344
+ echo -e "${SUCCESS}${BOLD} ║ ║${NC}"
2345
+ echo -e "${SUCCESS}${BOLD} ║ Open your browser and navigate to: ║${NC}"
2346
+ echo -e "${SUCCESS}${BOLD} ║ http://localhost:${JISHUSHELL_PORT}/$(printf '%*s' $((32 - ${#JISHUSHELL_PORT})) '')║${NC}"
2347
+ if [[ -n "$_local_ip" ]]; then
2348
+ echo -e "${SUCCESS}${BOLD} ║ http://${_local_ip}:${JISHUSHELL_PORT}/$(printf '%*s' $((28 - ${#_local_ip} - ${#JISHUSHELL_PORT})) '')║${NC}"
2349
+ fi
2350
+ echo -e "${SUCCESS}${BOLD} ╚══════════════════════════════════════════════════════╝${NC}"
2351
+ echo ""
2352
+ fi
2353
+ }
2354
+
2355
+ # ═══════════════════════════════════════════════════════════════════════════════
2356
+ # Core install orchestration
2357
+ # ═══════════════════════════════════════════════════════════════════════════════
2358
+
2359
+ # ─── 6. JishuShell backend (npm install -g jishushell) ───────────────────────
2360
+
2361
+ install_jishushell() {
2362
+ ui_stage "JishuShell"
2363
+
2364
+ if [[ $SKIP_JISHUSHELL -eq 1 ]]; then
2365
+ ui_info "Skipped (--skip-jishushell)"
2366
+ return 0
2367
+ fi
2368
+
2369
+ if [[ "$DRY_RUN" == "1" ]]; then
2370
+ local _dry_reg=""
2371
+ [[ -n "${NPM_REGISTRY:-}" ]] && _dry_reg=" --registry ${NPM_REGISTRY}"
2372
+ local _dry_tgz=""
2373
+ for _c in "${JISHU_SCRIPT_DIR}"/jishushell-*.tgz; do
2374
+ [[ -f "$_c" ]] && { _dry_tgz="$_c"; break; }
2375
+ done
2376
+ if [[ -n "$_dry_tgz" ]]; then
2377
+ ui_info "[dry-run] Would: npm install -g ${_dry_tgz} (local package)"
2378
+ else
2379
+ ui_info "[dry-run] Would: npm install -g jishushell${_dry_reg}"
2380
+ fi
2381
+ ui_info "[dry-run] Would write wrapper: ${JISHUSHELL_BIN_DIR}/jishushell-panel-start"
2382
+ return 0
2383
+ fi
2384
+
2385
+ local node_bin
2386
+ node_bin="$(command -v node 2>/dev/null || true)"
2387
+ # When running as root via sudo, node may only be in the real user's nvm.
2388
+ if [[ -z "$node_bin" && -n "${REAL_HOME}" ]]; then
2389
+ local nvm_node_dir="${REAL_HOME}/.nvm/versions/node"
2390
+ if [[ -d "$nvm_node_dir" ]]; then
2391
+ node_bin="$(find "$nvm_node_dir" -name node -type f 2>/dev/null | sort -V | tail -1 || true)"
2392
+ fi
2393
+ fi
2394
+ if [[ -z "$node_bin" ]]; then
2395
+ ui_error "Cannot locate node — wrapper cannot be written"
2396
+ return 1
2397
+ fi
2398
+
2399
+ # Locate npm sibling to the node binary (works with nvm-managed installs)
2400
+ local npm_bin
2401
+ npm_bin="$(dirname "$node_bin")/npm"
2402
+ if [[ ! -x "$npm_bin" ]]; then
2403
+ npm_bin="$(command -v npm 2>/dev/null || true)"
2404
+ fi
2405
+ if [[ -z "$npm_bin" ]]; then
2406
+ ui_error "Cannot locate npm — cannot install jishushell"
2407
+ return 1
2408
+ fi
2409
+
2410
+ local npm_registry_args=();
2411
+ if [[ -n "${NPM_REGISTRY:-}" ]]; then
2412
+ if [[ ! "$NPM_REGISTRY" =~ ^https?:// ]]; then
2413
+ ui_error "NPM_REGISTRY must be a valid URL starting with http:// or https://"
2414
+ return 1
2415
+ fi
2416
+ npm_registry_args=("--registry" "${NPM_REGISTRY}")
2417
+ ui_info "Installing jishushell from ${NPM_REGISTRY}..."
2418
+ elif [[ "${NETWORK_CHINA:-0}" == "1" ]]; then
2419
+ npm_registry_args=("--registry" "https://registry.npmmirror.com")
2420
+ ui_info "China network — installing jishushell from npmmirror.com..."
2421
+ else
2422
+ ui_info "Installing jishushell from public npm registry..."
2423
+ fi
2424
+
2425
+ # When jishushell is already installed (e.g. running as npm postinstall hook),
2426
+ # skip the npm install step and only write the wrapper + service.
2427
+ if [[ "${JISHUSHELL_SKIP_NPM_INSTALL:-0}" != "1" ]]; then
2428
+ # Prefer a local .tgz package in the same directory as this script.
2429
+ local tgz_path=""
2430
+ local _tgz_candidate
2431
+ for _tgz_candidate in "${JISHU_SCRIPT_DIR}"/jishushell-*.tgz; do
2432
+ if [[ -f "$_tgz_candidate" ]]; then
2433
+ tgz_path="$_tgz_candidate"
2434
+ break
2435
+ fi
2436
+ done
2437
+
2438
+ # Export a sentinel so post-install.sh (triggered by npm's postinstall
2439
+ # lifecycle hook) knows it was launched from inside jishu-install.sh and
2440
+ # must not re-run Docker/Nomad/OpenClaw installation steps again.
2441
+ export JISHU_RUNNING_IN_INSTALLER=1
2442
+
2443
+ if [[ -n "$tgz_path" ]]; then
2444
+ ui_info "Found local package: ${tgz_path} — installing offline..."
2445
+ log_detail "[$(date '+%H:%M:%S')] ${npm_bin} install -g ${tgz_path}"
2446
+ if ! log_cmd "$npm_bin" install -g "${tgz_path}"; then
2447
+ unset JISHU_RUNNING_IN_INSTALLER
2448
+ ui_error "npm install -g ${tgz_path} failed"
2449
+ return 1
2450
+ fi
2451
+ else
2452
+ log_detail "[$(date '+%H:%M:%S')] ${npm_bin} install -g jishushell ${npm_registry_args[*]:-}"
2453
+ if ! log_cmd "$npm_bin" install -g jishushell ${npm_registry_args[@]+"${npm_registry_args[@]}"}; then
2454
+ unset JISHU_RUNNING_IN_INSTALLER
2455
+ ui_error "npm install -g jishushell failed"
2456
+ return 1
2457
+ fi
2458
+ fi
2459
+ unset JISHU_RUNNING_IN_INSTALLER
2460
+ else
2461
+ ui_info "Skipping npm install (already installed by caller)"
2462
+ fi
2463
+
2464
+ # Resolve the installed cli.js path via the npm that did the install
2465
+ local npm_root
2466
+ npm_root="$("$npm_bin" root -g 2>/dev/null || true)"
2467
+ if [[ -z "$npm_root" ]]; then
2468
+ ui_error "Cannot locate npm root — wrapper cannot be written"
2469
+ return 1
2470
+ fi
2471
+ local jishushell_bin="${npm_root}/jishushell/dist/cli.js"
2472
+ ui_success "JishuShell installed ($(${node_bin} -p "require('${npm_root}/jishushell/package.json').version" 2>/dev/null || echo 'version unknown'))"
2473
+ local wrapper="${JISHUSHELL_BIN_DIR}/jishushell-panel-start"
2474
+ mkdir -p "${JISHUSHELL_BIN_DIR}"
2475
+
2476
+ # The wrapper resolves node at runtime — it does NOT hardcode the path so
2477
+ # the binary stays valid if nvm is upgraded or node is reinstalled.
2478
+ cat > "$wrapper" << WRAPPER
2479
+ #!/usr/bin/env bash
2480
+ set -euo pipefail
2481
+
2482
+ _find_node() {
2483
+ command -v node 2>/dev/null && return
2484
+ local nvm_dir="\${NVM_DIR:-\${HOME}/.nvm}"
2485
+ [ -s "\$nvm_dir/nvm.sh" ] && . "\$nvm_dir/nvm.sh" --no-use 2>/dev/null || true
2486
+ command -v node 2>/dev/null && return
2487
+ local ndir="\${HOME}/.nvm/versions/node"
2488
+ [ -d "\$ndir" ] && find "\$ndir" -name node -type f 2>/dev/null | sort -V | tail -1 || true
2489
+ }
2490
+ NODE_BIN="\$(_find_node || true)"
2491
+ if [ -z "\$NODE_BIN" ]; then
2492
+ NODE_BIN="/opt/homebrew/bin/node"
2493
+ fi
2494
+ if [ ! -x "\$NODE_BIN" ]; then
2495
+ echo "[jishushell-panel-start] ERROR: cannot locate node binary" >&2
2496
+ exit 1
2497
+ fi
2498
+
2499
+ # Data directory: honour explicit env override, otherwise use the real
2500
+ # user home embedded at install time (avoids /root when run as root).
2501
+ JISHUSHELL_HOME="\${JISHUSHELL_HOME:-${REAL_HOME}/.jishushell}"
2502
+ NOMAD_ENV="\${JISHUSHELL_HOME}/nomad.env"
2503
+
2504
+ [ -f "\$NOMAD_ENV" ] && source "\$NOMAD_ENV"
2505
+
2506
+ if [ ! -f "${jishushell_bin}" ]; then
2507
+ echo "[jishushell-panel-start] ERROR: could not find jishushell at ${jishushell_bin}" >&2
2508
+ exit 1
2509
+ fi
2510
+
2511
+ exec "\$NODE_BIN" "${jishushell_bin}" "\$@"
2512
+ WRAPPER
2513
+ chmod +x "$wrapper"
2514
+ ui_success "JishuShell installed — wrapper: ${wrapper}"
2515
+ }
2516
+
2517
+ install_jishushell_service() {
2518
+ ui_stage "JishuShell service"
2519
+
2520
+ if [[ $SKIP_JISHUSHELL_SERVICE -eq 1 ]]; then
2521
+ ui_info "Skipped (--skip-jishushell-service)"
2522
+ return 0
2523
+ fi
2524
+
2525
+ local wrapper="${JISHUSHELL_BIN_DIR}/jishushell-panel-start"
2526
+ local log_path="${JISHUSHELL_HOME}/jishushell.log"
2527
+
2528
+ if [[ "$DRY_RUN" == "1" ]]; then
2529
+ ui_info "[dry-run] Would register jishushell as a system service"
2530
+ return 0
2531
+ fi
2532
+
2533
+ if [[ "$OS" == "macos" ]]; then
2534
+ local plist_label="com.jishushell.panel"
2535
+ local plist_path="${HOME}/Library/LaunchAgents/${plist_label}.plist"
2536
+
2537
+ mkdir -p "${HOME}/Library/LaunchAgents"
2538
+ cat > "$plist_path" << PLIST
2539
+ <?xml version="1.0" encoding="UTF-8"?>
2540
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2541
+ <plist version="1.0">
2542
+ <dict>
2543
+ <key>Label</key>
2544
+ <string>${plist_label}</string>
2545
+ <key>ProgramArguments</key>
2546
+ <array>
2547
+ <string>${wrapper}</string>
2548
+ <string>serve</string>
2549
+ </array>
2550
+ <key>RunAtLoad</key>
2551
+ <true/>
2552
+ <key>KeepAlive</key>
2553
+ <true/>
2554
+ <key>StandardOutPath</key>
2555
+ <string>${log_path}</string>
2556
+ <key>StandardErrorPath</key>
2557
+ <string>${log_path}</string>
2558
+ </dict>
2559
+ </plist>
2560
+ PLIST
2561
+
2562
+ launchctl unload "$plist_path" 2>/dev/null || true
2563
+ if launchctl load -w "$plist_path" 2>/dev/null; then
2564
+ ui_success "JishuShell backend service installed and started"
2565
+ else
2566
+ ui_warn "Could not load jishushell launchd agent"
2567
+ fi
2568
+ return 0
2569
+ fi
2570
+
2571
+ if [[ "$(uname -s)" != "Linux" ]]; then
2572
+ return 0
2573
+ fi
2574
+
2575
+ local service_content="[Unit]
2576
+ Description=JishuShell Backend
2577
+ After=network-online.target nomad.service
2578
+ Wants=network-online.target
2579
+
2580
+ [Service]
2581
+ Type=simple
2582
+ User=${REAL_USER}
2583
+ SupplementaryGroups=docker
2584
+ EnvironmentFile=-/etc/jishushell/nomad.env
2585
+ ExecStart=${wrapper} serve
2586
+ Restart=on-failure
2587
+ RestartSec=3
2588
+ Environment=HOME=${REAL_HOME}
2589
+ Environment=JISHUSHELL_HOME=${JISHUSHELL_HOME}
2590
+ ProtectSystem=strict
2591
+ PrivateTmp=true
2592
+ NoNewPrivileges=true
2593
+ ReadWritePaths=${JISHUSHELL_HOME} /etc/jishushell
2594
+
2595
+ [Install]
2596
+ WantedBy=multi-user.target"
2597
+
2598
+ # Only update the service file if it changed (avoids unnecessary restarts)
2599
+ local svc_path="/etc/systemd/system/jishushell.service"
2600
+ local need_reload=0
2601
+ if [[ ! -f "$svc_path" ]] || ! echo "$service_content" | diff -q - "$svc_path" &>/dev/null; then
2602
+ echo "$service_content" > /tmp/jishushell.service.$$
2603
+ ${SUDO} mv /tmp/jishushell.service.$$ "$svc_path"
2604
+ need_reload=1
2605
+ fi
2606
+
2607
+ if [[ $need_reload -eq 1 ]]; then
2608
+ ${SUDO} systemctl daemon-reload
2609
+ fi
2610
+
2611
+ # Enable the service (auto-start on boot).
2612
+ # Use 'enable --now' to also start it immediately — jishushell is the final
2613
+ # component so there is no double-wait concern unlike Nomad.
2614
+ if ! systemctl is-enabled jishushell &>/dev/null 2>&1; then
2615
+ if ${SUDO} systemctl enable --now jishushell 2>/dev/null; then
2616
+ ui_success "JishuShell systemd service installed and started"
2617
+ else
2618
+ ui_warn "Could not enable jishushell systemd service"
2619
+ fi
2620
+ elif [[ $need_reload -eq 1 ]]; then
2621
+ # Service file changed — restart to pick up new config
2622
+ ${SUDO} systemctl restart jishushell 2>/dev/null || true
2623
+ ui_success "JishuShell systemd service updated and restarted"
2624
+ else
2625
+ ui_success "JishuShell systemd service already installed"
2626
+ fi
2627
+ }
2628
+
2629
+ # ─── run_install_components ───────────────────────────────────────────────────
2630
+ # Runs the standard install sequence. Returns non-zero if any component fails.
2631
+ run_install_components() {
2632
+ local with_jishushell=0
2633
+ [[ "${1:-}" == "--with-jishushell" ]] && with_jishushell=1
2634
+
2635
+ local has_error=0
2636
+
2637
+ if [[ $SKIP_NODE -eq 0 ]]; then
2638
+ if ! install_node; then
2639
+ ui_error "Node.js installation failed"
2640
+ has_error=1
2641
+ fi
2642
+ else
2643
+ ui_stage "Node.js"
2644
+ ui_info "Skipped (--skip-node)"
2645
+ fi
2646
+
2647
+ local docker_ok=0
2648
+ if [[ $SKIP_DOCKER -eq 0 ]]; then
2649
+ if ! install_docker; then
2650
+ ui_error "Docker installation failed"
2651
+ has_error=1
2652
+ else
2653
+ docker_ok=1
2654
+ fi
2655
+ else
2656
+ ui_stage "Docker"
2657
+ ui_info "Skipped (--skip-docker)"
2658
+ command -v docker &>/dev/null && docker_ok=1
2659
+ fi
2660
+
2661
+ if [[ $SKIP_NOMAD -eq 0 ]]; then
2662
+ if ! install_nomad; then
2663
+ ui_error "Nomad installation failed"
2664
+ has_error=1
2665
+ else
2666
+ install_nomad_systemd || true
2667
+ # Ensure Nomad is actually running (mirrors install.ts startNomad() call).
2668
+ # start_nomad() is idempotent: on Linux it defers to systemd (no competing nohup);
2669
+ # on macOS/non-systemd it starts a background process.
2670
+ # A slow start (timeout) is non-fatal — systemd will retry on next boot and
2671
+ # 'jishushell doctor --fix' can recover it. Do not fail the entire npm install
2672
+ # just because Nomad took longer than expected to bind its port.
2673
+ if ! start_nomad; then
2674
+ ui_warn "Nomad could not be started — run 'jishushell doctor --fix' to diagnose"
2675
+ fi
2676
+ fi
2677
+ else
2678
+ ui_stage "Nomad"
2679
+ ui_info "Skipped (--skip-nomad)"
2680
+ fi
2681
+
2682
+ if [[ $docker_ok -eq 1 ]]; then
2683
+ if ! install_openclaw; then
2684
+ ui_error "OpenClaw installation failed"
2685
+ has_error=1
2686
+ fi
2687
+ else
2688
+ ui_stage "OpenClaw"
2689
+ ui_warn "Skipped — Docker is not available (installation failed or not installed)"
2690
+ fi
2691
+
2692
+ if [[ $with_jishushell -eq 1 ]]; then
2693
+ if [[ $SKIP_JISHUSHELL -eq 1 ]]; then
2694
+ ui_stage "JishuShell"
2695
+ ui_info "Skipped (--skip-jishushell)"
2696
+ elif ! install_jishushell; then
2697
+ ui_error "JishuShell installation failed"
2698
+ has_error=1
2699
+ fi
2700
+
2701
+ if [[ $SKIP_JISHUSHELL -eq 0 && $SKIP_JISHUSHELL_SERVICE -eq 1 ]]; then
2702
+ ui_stage "JishuShell service"
2703
+ ui_info "Skipped (--skip-jishushell-service)"
2704
+ elif [[ $SKIP_JISHUSHELL -eq 0 ]]; then
2705
+ install_jishushell_service || true
2706
+ fi
2707
+ fi
2708
+
2709
+ # ── Fix .jishushell ownership & permissions ───────────────────────────────
2710
+ # Ensure the data dir is readable/writable by both root and the real user.
2711
+ # - Root owns the dir (service runs as root)
2712
+ # - REAL_USER is in the owning group; dirs are g+rwx so normal-user tools work
2713
+ if [[ -d "${JISHUSHELL_HOME}" && -n "${REAL_USER}" ]]; then
2714
+ ui_info "Fixing ${JISHUSHELL_HOME} ownership for ${REAL_USER}..."
2715
+ # Both Nomad and JishuShell now run as REAL_USER — make REAL_USER own everything
2716
+ ${SUDO} chown -R "${REAL_USER}:${REAL_GID:-${REAL_USER}}" "${JISHUSHELL_HOME}" 2>/dev/null || true
2717
+ # Dirs: rwxr-xr-x
2718
+ ${SUDO} find "${JISHUSHELL_HOME}" -type d -exec chmod 755 {} + 2>/dev/null || true
2719
+ # Files: rw-r--r-- by default
2720
+ ${SUDO} find "${JISHUSHELL_HOME}" -type f -exec chmod 644 {} + 2>/dev/null || true
2721
+ # Executables in bin/ must keep the execute bit
2722
+ ${SUDO} find "${JISHUSHELL_BIN_DIR}" -type f -exec chmod 755 {} + 2>/dev/null || true
2723
+ # Sensitive files: owner-only read (600)
2724
+ for f in auth.json jwt-secret panel.json nomad.env encryption-key; do
2725
+ local fp="${JISHUSHELL_HOME}/${f}"
2726
+ [[ -f "$fp" ]] && ${SUDO} chmod 600 "$fp" 2>/dev/null || true
2727
+ done
2728
+ # Sync nomad.env from /etc/jishushell if missing in JISHUSHELL_HOME
2729
+ if [[ ! -f "${JISHUSHELL_HOME}/nomad.env" && -f /etc/jishushell/nomad.env ]]; then
2730
+ ${SUDO} cp /etc/jishushell/nomad.env "${JISHUSHELL_HOME}/nomad.env"
2731
+ ${SUDO} chown "${REAL_USER}:${REAL_GID:-${REAL_USER}}" "${JISHUSHELL_HOME}/nomad.env"
2732
+ ${SUDO} chmod 600 "${JISHUSHELL_HOME}/nomad.env"
2733
+ fi
2734
+ ui_success "Permissions set: ${REAL_USER}:${REAL_USER} on ${JISHUSHELL_HOME}"
2735
+ fi
2736
+
2737
+ return $has_error
2738
+ }
2739
+
2740
+ # ═══════════════════════════════════════════════════════════════════════════════
2741
+ # Installer entry point — argument parsing, banner, main
2742
+ # ═══════════════════════════════════════════════════════════════════════════════
2743
+
2744
+ parse_args() {
2745
+ while [[ $# -gt 0 ]]; do
2746
+ case "$1" in
2747
+ --verbose) VERBOSE=1 ;;
2748
+ --dry-run) DRY_RUN=1 ;;
2749
+ --skip-node) SKIP_NODE=1 ;;
2750
+ --skip-docker) SKIP_DOCKER=1 ;;
2751
+ --skip-nomad) SKIP_NOMAD=1 ;;
2752
+ --skip-openclaw) SKIP_OPENCLAW=1 ;;
2753
+ --openclaw-version)
2754
+ shift
2755
+ OPENCLAW_NPM_VERSION="${1:?--openclaw-version requires a version argument (e.g. 3.24)}"
2756
+ ;;
2757
+ --skip-jishushell) SKIP_JISHUSHELL=1 ;;
2758
+ --skip-jishushell-service) SKIP_JISHUSHELL_SERVICE=1 ;;
2759
+ --skip)
2760
+ shift
2761
+ IFS=',' read -ra _steps <<< "${1:-}"
2762
+ for _s in "${_steps[@]}"; do
2763
+ case "$_s" in
2764
+ 1) SKIP_NODE=1 ;;
2765
+ 2) SKIP_DOCKER=1 ;;
2766
+ 3) SKIP_NOMAD=1 ;;
2767
+ 4) SKIP_OPENCLAW=1 ;;
2768
+ 5) SKIP_JISHUSHELL=1 ;;
2769
+ 6) SKIP_JISHUSHELL_SERVICE=1 ;;
2770
+ *) ui_warn "Unknown step number: $_s (valid: 1-6)" ;;
2771
+ esac
2772
+ done
2773
+ ;;
2774
+ --run)
2775
+ # --run N,M → skip all steps NOT in the list
2776
+ # First set all to skip, then re-enable only the listed steps
2777
+ SKIP_NODE=1; SKIP_DOCKER=1; SKIP_NOMAD=1
2778
+ SKIP_OPENCLAW=1; SKIP_JISHUSHELL=1; SKIP_JISHUSHELL_SERVICE=1
2779
+ shift
2780
+ IFS=',' read -ra _steps <<< "${1:-}"
2781
+ for _s in "${_steps[@]}"; do
2782
+ case "$_s" in
2783
+ 1) SKIP_NODE=0 ;;
2784
+ 2) SKIP_DOCKER=0 ;;
2785
+ 3) SKIP_NOMAD=0 ;;
2786
+ 4) SKIP_OPENCLAW=0 ;;
2787
+ 5) SKIP_JISHUSHELL=0 ;;
2788
+ 6) SKIP_JISHUSHELL_SERVICE=0 ;;
2789
+ *) ui_warn "Unknown step number: $_s (valid: 1-6)" ;;
2790
+ esac
2791
+ done
2792
+ ;;
2793
+ --registry)
2794
+ shift
2795
+ NPM_REGISTRY="${1:?--registry requires a URL argument}"
2796
+ if [[ ! "$NPM_REGISTRY" =~ ^https?:// ]]; then
2797
+ ui_error "--registry must be a valid URL starting with http:// or https://"
2798
+ exit 1
2799
+ fi
2800
+ ;;
2801
+ --network)
2802
+ shift
2803
+ case "${1:-}" in
2804
+ china) NETWORK_CHINA=1 ;;
2805
+ global) NETWORK_CHINA=0 ;;
2806
+ *) ui_error "--network requires 'china' or 'global'"; exit 1 ;;
2807
+ esac
2808
+ NETWORK_OVERRIDE=1
2809
+ ;;
2810
+ --yes|-y) AUTO_YES=1 ;;
2811
+ --help|-h) usage; exit 0 ;;
2812
+ *) ui_warn "Unknown argument: $1" ;;
2813
+ esac
2814
+ shift
2815
+ done
2816
+ }
2817
+
2818
+ usage() {
2819
+ cat <<EOF
2820
+ Usage: bash jishu-install.sh [options]
2821
+
2822
+ Default (no options): runs steps 1,2,3,4,5,6
2823
+
2824
+ Options:
2825
+ --verbose Show verbose output
2826
+ --dry-run Show install plan only, do not execute
2827
+ --run <steps> Run only the specified steps (comma-separated, e.g. --run 1,2,3)
2828
+ --skip <steps> Skip steps by number (comma-separated, e.g. --skip 4)
2829
+ --skip-node Skip step 1: Node.js installation
2830
+ --skip-docker Skip step 2: Docker installation
2831
+ --skip-nomad Skip step 3: Nomad installation
2832
+ --skip-openclaw Skip step 4: OpenClaw installation
2833
+ --skip-jishushell Skip step 5: JishuShell installation
2834
+ --skip-jishushell-service Skip step 6: JishuShell service registration
2835
+ --registry <url> Use a custom npm registry for all installs
2836
+ (e.g. --registry http://10.188.0.22:4873/)
2837
+ --network <china|global> Force network mode (skips auto-detection via ping)
2838
+ china: use Alibaba mirrors for Docker and npm
2839
+ global: use default upstream sources
2840
+ --yes, -y Skip all confirmation prompts
2841
+ --help, -h Show this help message
2842
+
2843
+ Steps:
2844
+ 1 Node.js (via nvm)
2845
+ 2 Docker
2846
+ 3 Nomad
2847
+ 4 OpenClaw (npm install + docker build)
2848
+ 5 JishuShell
2849
+ 6 JishuShell service registration (autostart)
2850
+
2851
+ Environment variables:
2852
+ JISHU_NODE_VERSION Specify Node.js major version (default: ${NODE_VERSION})
2853
+ JISHU_NVM_VERSION Specify nvm version (default: ${NVM_VERSION})
2854
+ JISHU_NOMAD_VERSION Specify Nomad version (default: ${NOMAD_VERSION})
2855
+ OPENCLAW_NPM_VERSION Specify openclaw npm package version (default: latest)
2856
+ OPENCLAW_DOCKER_TAG Override built Docker image tag (default: jishushell-base:v1)
2857
+ NPM_REGISTRY Custom npm registry URL (same as --registry flag)
2858
+
2859
+ OpenClaw version flag (equivalent to OPENCLAW_NPM_VERSION env var):
2860
+ --openclaw-version <ver> Install a specific openclaw version, e.g. --openclaw-version 3.24
2861
+ NO_PROMPT Set to 1 to skip interactive prompts
2862
+ VERBOSE Set to 1 for verbose output
2863
+
2864
+ Examples:
2865
+ curl -fsSL https://www.aijishu.com/jishu-install.sh | bash
2866
+ NO_PROMPT=1 bash jishu-install.sh --dry-run
2867
+ EOF
2868
+ }
2869
+
2870
+ print_banner() {
2871
+ echo -e "${ACCENT}${BOLD}"
2872
+ echo " ╔══════════════════════════════════════╗"
2873
+ echo " ║ JishuShell Installer ║"
2874
+ echo " ╚══════════════════════════════════════╝"
2875
+ echo -e "${NC}${INFO} ${TAGLINE}${NC}"
2876
+ echo ""
2877
+ }
2878
+
2879
+ # ─── Install mode selection ───────────────────────────────────────────────────
2880
+ _prompt_install_confirm() {
2881
+ if [[ "${AUTO_YES:-0}" == "1" || "${NO_PROMPT:-0}" == "1" ]]; then
2882
+ return 0
2883
+ fi
2884
+ # Check /dev/tty directly — stdout/stdin may be redirected to the log FIFO
2885
+ # so the standard -t 0/-t 1 tests are unreliable here.
2886
+ if [[ ! -w /dev/tty || ! -r /dev/tty ]]; then
2887
+ AUTO_YES=1
2888
+ ui_info "Non-interactive shell detected — proceeding automatically"
2889
+ return 0
2890
+ fi
2891
+
2892
+ # Write everything directly to /dev/tty so output bypasses the log FIFO
2893
+ # and is guaranteed to appear on screen before `read` blocks for input.
2894
+ {
2895
+ echo -e "${ACCENT}${BOLD}┌─────────────────────────────────────────────────────────┐${NC}"
2896
+ echo -e "${ACCENT}${BOLD}│ THIRD-PARTY SOFTWARE NOTICE │${NC}"
2897
+ echo -e "${ACCENT}${BOLD}└─────────────────────────────────────────────────────────┘${NC}"
2898
+ echo ""
2899
+ echo -e " JishuShell assists in downloading and configuring the following"
2900
+ echo -e " third-party software packages for personal and internal use only."
2901
+ echo -e " Each package is governed solely by its own license. JishuShell"
2902
+ echo -e " does not modify, relicense, or assert ownership over any of them."
2903
+ echo ""
2904
+ echo -e " You are the end user of these packages. You are solely"
2905
+ echo -e " responsible for ensuring that your use of each package complies"
2906
+ echo -e " with its respective license terms, including any restrictions on"
2907
+ echo -e " commercial use, competitive offerings, or redistribution."
2908
+ echo ""
2909
+
2910
+ if [[ $SKIP_DOCKER -eq 0 ]]; then
2911
+ echo -e " ${BOLD}Docker Engine${NC}"
2912
+ echo -e " ${MUTED} URL : https://github.com/moby/moby${NC}"
2913
+ echo -e " ${MUTED} License : Apache License, Version 2.0${NC}"
2914
+ echo -e " ${MUTED} https://www.apache.org/licenses/LICENSE-2.0${NC}"
2915
+ echo -e " ${MUTED} Author : Docker, Inc.${NC}"
2916
+ echo ""
2917
+ fi
2918
+ if [[ $SKIP_NOMAD -eq 0 ]]; then
2919
+ echo -e " ${BOLD}Nomad v${NOMAD_VERSION}+ (>= 1.7.0)${NC}"
2920
+ echo -e " ${MUTED} URL : https://github.com/hashicorp/nomad${NC}"
2921
+ echo -e " ${MUTED} License : Business Source License 1.1 (BSL 1.1)${NC}"
2922
+ echo -e " ${MUTED} https://github.com/hashicorp/nomad/blob/main/LICENSE${NC}"
2923
+ echo -e " ${MUTED} Licensor: International Business Machines Corporation (IBM)${NC}"
2924
+ echo -e " ${MUTED} Work : Nomad Version 1.7.0 or later. (c) 2024 IBM Corp.${NC}"
2925
+ echo ""
2926
+ fi
2927
+ echo -e " ${MUTED}─────────────────────────────────────────────────────────${NC}"
2928
+ echo -e " ${MUTED}By continuing you acknowledge that you have read the above${NC}"
2929
+ echo -e " ${MUTED}notices and agree to each package's license terms.${NC}"
2930
+ echo -e " ${MUTED}sudo privileges are required to write to system directories.${NC}"
2931
+ echo ""
2932
+ } >/dev/tty
2933
+
2934
+ local answer
2935
+ read -r -p "$(echo -e " ${WARN}I have read and accept the above notices. Continue? [Y/n]: ${NC}")" answer </dev/tty || answer="y"
2936
+ case "$(echo "$answer" | tr '[:upper:]' '[:lower:]')" in
2937
+ n|no)
2938
+ echo "Installation cancelled." >/dev/tty
2939
+ exit 0
2940
+ ;;
2941
+ esac
2942
+ echo "" >/dev/tty
2943
+ }
2944
+
2945
+ # Finalize the install log: restore fds, wait for tee, strip ANSI codes.
2946
+ # Called from the EXIT trap so it runs on normal exit, Ctrl+C, and errors.
2947
+ _jishu_finalize_log() {
2948
+ set +e
2949
+ [[ -z "${_JISHU_RAW_LOG:-}" ]] && return 0 # not started or already finalized
2950
+ local _raw="${_JISHU_RAW_LOG}"
2951
+ local _log="${JISHU_LOG_FILE}"
2952
+ local _fifo="${_JISHU_LOG_FIFO:-}"
2953
+ local _tee_pid="${_JISHU_TEE_PID:-}"
2954
+ # Mark as done to prevent re-entry
2955
+ local _detail="${_JISHU_DETAIL_LOG:-}"
2956
+ _JISHU_RAW_LOG=""; _JISHU_TEE_PID=""; _JISHU_LOG_FIFO=""; _JISHU_DETAIL_LOG=""
2957
+
2958
+ # ── Step 1: kill the sudo keepalive background process ────────────────────
2959
+ # The keepalive is spawned before the FIFO redirect (so it does NOT hold
2960
+ # the FIFO write-end). We still kill it here for clean process accounting.
2961
+ if [[ -n "${_SUDO_KEEPALIVE_PID:-}" ]]; then
2962
+ kill "${_SUDO_KEEPALIVE_PID}" 2>/dev/null || true
2963
+ _SUDO_KEEPALIVE_PID=""
2964
+ fi
2965
+
2966
+ # ── Step 2: restore original fds ─────────────────────────────────────────
2967
+ # Closing our fd1 (the FIFO write-end) now sends EOF to tee because the
2968
+ # keepalive (the only other writer) is already dead.
2969
+ exec 1>&3 2>/dev/null
2970
+ exec 2>&4 2>/dev/null
2971
+ exec 3>&- 2>/dev/null
2972
+ exec 4>&- 2>/dev/null
2973
+
2974
+ # ── Step 3: wait for tee with a 5-second safety timeout ──────────────────
2975
+ if [[ -n "$_tee_pid" ]]; then
2976
+ local _n=0
2977
+ while kill -0 "$_tee_pid" 2>/dev/null && [[ $_n -lt 50 ]]; do
2978
+ sleep 0.1
2979
+ _n=$((_n + 1))
2980
+ done
2981
+ # Safety kill if tee is somehow still alive (e.g. another process held
2982
+ # the FIFO open), so this function never hangs the terminal.
2983
+ if kill -0 "$_tee_pid" 2>/dev/null; then
2984
+ kill "$_tee_pid" 2>/dev/null || true
2985
+ sleep 0.2
2986
+ kill -9 "$_tee_pid" 2>/dev/null || true
2987
+ fi
2988
+ fi
2989
+
2990
+ # ── Step 4: remove the FIFO ───────────────────────────────────────────────
2991
+ [[ -n "$_fifo" ]] && rm -f "$_fifo" 2>/dev/null || true
2992
+
2993
+ # ── Step 5: produce the clean log ────────────────────────────────────────
2994
+ # Strip ANSI escape codes AND decorative box-drawing characters
2995
+ # (╔══╗, ╚══╝, ════, ────, ── text ──) so the log is plain readable text.
2996
+ if [[ -f "${_raw}" ]]; then
2997
+ sed \
2998
+ -e 's/\x1B\[[0-9;]*[A-Za-z]//g' \
2999
+ -e 's/\r//g' \
3000
+ -e 's/[╔╗╚╝╠╣╦╩╬║│]//g' \
3001
+ -e 's/[═─]\{2,\}//g' \
3002
+ -e '/^[[:space:]]*$/d' \
3003
+ "${_raw}" > "${_log}" 2>/dev/null
3004
+ rm -f "${_raw}"
3005
+ fi
3006
+ # ── Step 6: append detailed command output ────────────────────────────────
3007
+ if [[ -f "${_detail}" && -s "${_detail}" ]]; then
3008
+ {
3009
+ printf '\n\n--- DETAILED COMMAND OUTPUT ---\n'
3010
+ cat "${_detail}"
3011
+ } >> "${_log}" 2>/dev/null
3012
+ rm -f "${_detail}"
3013
+ fi
3014
+ echo "Log saved to: ${_log}"
3015
+ }
3016
+
3017
+ _jishu_install_main() {
3018
+ parse_args "$@"
3019
+
3020
+ # ── Set up log file ───────────────────────────────────────────────────────
3021
+ # Strategy:
3022
+ # 1. Create a named FIFO so we get an unambiguous tee PID via $!.
3023
+ # 2. Start tee with SIGINT/SIGTERM ignored — Ctrl+C won't kill it mid-write.
3024
+ # tee inherits the current (real terminal) stdout, so screen output is
3025
+ # preserved. Its stdin comes from the FIFO.
3026
+ # 3. Redirect all script output into the FIFO.
3027
+ # 4. EXIT trap calls _jishu_finalize_log which closes the FIFO write-end,
3028
+ # waits for tee with 'wait $PID', then strips ANSI codes to produce the
3029
+ # clean log. Works for normal exit, Ctrl+C, and set -e errors.
3030
+ local _log_ts
3031
+ _log_ts="$(date +%Y-%m-%d-%H-%M-%S)"
3032
+ JISHU_LOG_FILE="${JISHU_SCRIPT_DIR}/jishu-install-${_log_ts}.log"
3033
+ # Fall back to $PWD when the script directory is not writable (e.g. curl|bash).
3034
+ if [[ ! -w "${JISHU_SCRIPT_DIR}" ]]; then
3035
+ JISHU_LOG_FILE="${PWD}/jishu-install-${_log_ts}.log"
3036
+ fi
3037
+ _JISHU_RAW_LOG="${JISHU_LOG_FILE}.tmp"
3038
+ _JISHU_DETAIL_LOG="${JISHU_LOG_FILE}.detail"
3039
+
3040
+ # Create FIFO — $$ makes it unique per invocation, no mktemp race needed.
3041
+ _JISHU_LOG_FIFO="${TMPDIR:-/tmp}/jishu_log_$$.fifo"
3042
+ mkfifo "${_JISHU_LOG_FIFO}"
3043
+
3044
+ # Launch tee BEFORE redirecting so it inherits the real terminal as stdout.
3045
+ # trap '' INT TERM makes it immune to Ctrl+C and termination signals.
3046
+ ( trap '' INT TERM; exec tee "${_JISHU_RAW_LOG}" ) < "${_JISHU_LOG_FIFO}" &
3047
+ _JISHU_TEE_PID=$!
3048
+
3049
+ # Override EXIT trap: finalize log first, then run normal temp-file cleanup.
3050
+ trap '_jishu_finalize_log; cleanup_tmpfiles' EXIT
3051
+
3052
+ # ── Pre-FIFO keepalive: must be spawned BEFORE the exec redirect below so
3053
+ # that it inherits the real terminal as fd 1 rather than the FIFO write-end.
3054
+ # A keepalive holding the FIFO open would prevent tee from ever seeing EOF
3055
+ # and cause _jishu_finalize_log to hang. Credentials are acquired later,
3056
+ # inside check_sudo() (which runs after the user confirms the install plan),
3057
+ # so the keepalive's early sudo -n true calls are intentional no-ops.
3058
+ if [[ $EUID -ne 0 ]] && command -v sudo &>/dev/null; then
3059
+ if [[ -z "${_SUDO_KEEPALIVE_PID:-}" ]]; then
3060
+ ( while true; do sudo -n true 2>/dev/null; sleep 60; done ) &
3061
+ _SUDO_KEEPALIVE_PID=$!
3062
+ disown "$_SUDO_KEEPALIVE_PID" 2>/dev/null || true
3063
+ fi
3064
+ fi
3065
+
3066
+ # Save original terminal fds, then redirect everything into the FIFO.
3067
+ exec 3>&1 4>&2
3068
+ exec > "${_JISHU_LOG_FIFO}" 2>&1
3069
+
3070
+ print_banner
3071
+ ui_info "Logging to: ${JISHU_LOG_FILE}"
3072
+ detect_os
3073
+ detect_arch
3074
+ show_install_plan --with-jishushell
3075
+ if [[ "$OS" == "macos" ]]; then
3076
+ ui_warn "macOS is not supported yet — coming soon!"
3077
+ exit 0
3078
+ fi
3079
+ _prompt_install_confirm
3080
+ check_sudo
3081
+ ensure_prerequisites
3082
+ run_install_components --with-jishushell
3083
+ local rc=$?
3084
+ show_summary --with-jishushell
3085
+ exit $rc
3086
+ }
3087
+
3088
+ # Only run main() when executed directly or piped via curl|bash (not when sourced).
3089
+ # When piped, BASH_SOURCE[0] is unset; when sourced it differs from $0.
3090
+ if [[ "${BASH_SOURCE[0]:-}" == "${0}" ]] || [[ -z "${BASH_SOURCE[0]:-}" ]]; then
3091
+ _jishu_install_main "$@"
3092
+ fi