jishushell 0.0.1 → 0.4.2

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