openclawsetup 1.0.13 → 1.0.15

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 (3) hide show
  1. package/bin/cli.mjs +147 -13
  2. package/install.sh +166 -36
  3. package/package.json +1 -1
package/bin/cli.mjs CHANGED
@@ -170,6 +170,128 @@ function safeExec(cmd, options = {}) {
170
170
  }
171
171
  }
172
172
 
173
+ const ETA_SAMPLE_BYTES = 256 * 1024;
174
+ const ETA_FACTOR = 2.0;
175
+ const ETA_OVERHEAD = 8;
176
+
177
+ async function fetchText(url, timeoutMs = 2000) {
178
+ const controller = new AbortController();
179
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
180
+ try {
181
+ const res = await fetch(url, { signal: controller.signal });
182
+ if (!res.ok) return '';
183
+ return (await res.text()).trim();
184
+ } catch {
185
+ return '';
186
+ } finally {
187
+ clearTimeout(timer);
188
+ }
189
+ }
190
+
191
+ function extractIPv4(text) {
192
+ const match = text.match(/\b(\d{1,3}\.){3}\d{1,3}\b/);
193
+ return match ? match[0] : '';
194
+ }
195
+
196
+ async function getPublicIP() {
197
+ const services = ['https://ifconfig.me/ip', 'https://icanhazip.com', 'https://ipinfo.io/ip'];
198
+ for (const url of services) {
199
+ const text = await fetchText(url);
200
+ const ip = extractIPv4(text);
201
+ if (ip && !ip.startsWith('127.')) return ip;
202
+ }
203
+
204
+ const result = safeExec('hostname -I 2>/dev/null || hostname -i 2>/dev/null');
205
+ if (result.ok && result.output) {
206
+ const ip = extractIPv4(result.output);
207
+ if (ip && !ip.startsWith('127.')) return ip;
208
+ }
209
+ return '';
210
+ }
211
+
212
+ function getNpmDistInfo(pkgName) {
213
+ const tarballResult = safeExec(`npm view ${pkgName}@latest dist.tarball`);
214
+ const sizeResult = safeExec(`npm view ${pkgName}@latest dist.size`);
215
+ const tarball = tarballResult.ok ? tarballResult.output.trim() : '';
216
+ const size = sizeResult.ok ? parseInt(sizeResult.output.trim(), 10) : 0;
217
+ return { tarball, size: Number.isFinite(size) ? size : 0 };
218
+ }
219
+
220
+ async function fetchContentLength(url, timeoutMs = 4000) {
221
+ const controller = new AbortController();
222
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
223
+ try {
224
+ const res = await fetch(url, { method: 'HEAD', signal: controller.signal });
225
+ const len = res.headers.get('content-length');
226
+ const size = len ? parseInt(len, 10) : 0;
227
+ return Number.isFinite(size) ? size : 0;
228
+ } catch {
229
+ return 0;
230
+ } finally {
231
+ clearTimeout(timer);
232
+ }
233
+ }
234
+
235
+ async function measureDownloadSpeed(url, sampleBytes = ETA_SAMPLE_BYTES, timeoutMs = 5000) {
236
+ let received = 0;
237
+ const controller = new AbortController();
238
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
239
+ const start = Date.now();
240
+
241
+ try {
242
+ const res = await fetch(url, {
243
+ headers: { Range: `bytes=0-${sampleBytes - 1}` },
244
+ signal: controller.signal,
245
+ });
246
+
247
+ if (!res.ok && res.status !== 206 && res.status !== 200) {
248
+ return 0;
249
+ }
250
+
251
+ const reader = res.body?.getReader();
252
+ if (!reader) return 0;
253
+
254
+ while (received < sampleBytes) {
255
+ const { done, value } = await reader.read();
256
+ if (done) break;
257
+ received += value.length;
258
+ if (received >= sampleBytes) {
259
+ controller.abort();
260
+ break;
261
+ }
262
+ }
263
+ } catch {
264
+ // ignore
265
+ } finally {
266
+ clearTimeout(timer);
267
+ }
268
+
269
+ const seconds = (Date.now() - start) / 1000;
270
+ if (seconds <= 0 || received <= 0) return 0;
271
+ return received / seconds;
272
+ }
273
+
274
+ async function estimateInstallEta(pkgName) {
275
+ try {
276
+ const { tarball, size: distSize } = getNpmDistInfo(pkgName);
277
+ if (!tarball) return null;
278
+
279
+ let size = distSize;
280
+ if (!size) {
281
+ size = await fetchContentLength(tarball);
282
+ }
283
+ if (!size) return null;
284
+
285
+ const speed = await measureDownloadSpeed(tarball);
286
+ if (!speed) return null;
287
+
288
+ const estimate = Math.round((size * ETA_FACTOR) / speed + ETA_OVERHEAD);
289
+ return Math.max(5, estimate);
290
+ } catch {
291
+ return null;
292
+ }
293
+ }
294
+
173
295
  // 检查 Node.js 版本
174
296
  function checkNodeVersion() {
175
297
  const version = process.version;
@@ -236,10 +358,11 @@ async function checkAndUpdate(cliName) {
236
358
 
237
359
  // 更新到最新版本
238
360
  log.info('正在更新到最新版本...');
361
+ const etaSeconds = await estimateInstallEta(cliName);
239
362
  const updateResult = await execWithProgress(
240
363
  `npm install -g ${cliName}@latest`,
241
364
  `正在更新 ${cliName}...`,
242
- { timeout: 300000 }
365
+ { timeout: 300000, etaSeconds }
243
366
  );
244
367
 
245
368
  if (updateResult.ok) {
@@ -388,10 +511,15 @@ function execWithProgress(cmd, message, options = {}) {
388
511
  const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
389
512
  let frameIndex = 0;
390
513
  let seconds = 0;
514
+ const { etaSeconds, ...spawnOptions } = options;
515
+ const totalSeconds = Number.isFinite(etaSeconds) ? Math.max(1, Math.floor(etaSeconds)) : null;
391
516
 
392
517
  // 显示进度动画
393
518
  const spinner = setInterval(() => {
394
- process.stdout.write(`\r${colors.cyan(frames[frameIndex])} ${message} ${colors.gray(`(${seconds}s)`)}`);
519
+ const remaining = totalSeconds !== null ? Math.max(0, totalSeconds - seconds) : null;
520
+ const etaText =
521
+ totalSeconds !== null ? colors.gray(`(剩余约${remaining}s)`) : colors.gray(`(${seconds}s)`);
522
+ process.stdout.write(`\r${colors.cyan(frames[frameIndex])} ${message} ${etaText}`);
395
523
  frameIndex = (frameIndex + 1) % frames.length;
396
524
  }, 100);
397
525
 
@@ -403,7 +531,7 @@ function execWithProgress(cmd, message, options = {}) {
403
531
  // 执行命令
404
532
  const child = spawn('sh', ['-c', cmd], {
405
533
  stdio: ['ignore', 'pipe', 'pipe'],
406
- ...options,
534
+ ...spawnOptions,
407
535
  });
408
536
 
409
537
  let stdout = '';
@@ -444,19 +572,21 @@ async function installOpenClaw() {
444
572
  log.step(1, '安装 OpenClaw CLI...');
445
573
  log.detail('正在从 npm 下载,请稍候...');
446
574
 
575
+ const etaSeconds = await estimateInstallEta('openclaw');
447
576
  const result = await execWithProgress(
448
577
  'npm install -g openclaw@latest',
449
578
  '正在安装 openclaw...',
450
- { timeout: 300000 }
579
+ { timeout: 300000, etaSeconds }
451
580
  );
452
581
 
453
582
  if (!result.ok) {
454
583
  // 尝试 clawdbot 作为备选
455
584
  log.warn('openclaw 安装失败,尝试 clawdbot...');
585
+ const fallbackEta = await estimateInstallEta('clawdbot');
456
586
  const fallback = await execWithProgress(
457
587
  'npm install -g clawdbot@latest',
458
588
  '正在安装 clawdbot...',
459
- { timeout: 300000 }
589
+ { timeout: 300000, etaSeconds: fallbackEta }
460
590
  );
461
591
  if (!fallback.ok) {
462
592
  exitWithError('NPM_INSTALL_FAILED', result.error || result.stderr, [
@@ -707,7 +837,7 @@ function startService(cliName) {
707
837
  }
708
838
 
709
839
  // 显示安装完成信息
710
- function showCompletionInfo(cliName, config, isVPS) {
840
+ function showCompletionInfo(cliName, config, isVPS, serverIP = '') {
711
841
  console.log(colors.bold(colors.green('\n========================================')));
712
842
  console.log(colors.bold(colors.green('✅ OpenClaw 安装完成!')));
713
843
  console.log(colors.bold(colors.green('========================================')));
@@ -719,12 +849,13 @@ function showCompletionInfo(cliName, config, isVPS) {
719
849
 
720
850
  // 根据环境显示不同的访问说明
721
851
  if (isVPS) {
852
+ const ipDisplay = serverIP || '<服务器IP>';
722
853
  console.log(colors.cyan('\n📡 Dashboard 访问 (云服务器):'));
723
854
  console.log(colors.gray(' Gateway 默认绑定 127.0.0.1,外部无法直接访问(安全设计)'));
724
855
  console.log('');
725
856
  console.log(colors.yellow(' 方式一:SSH 隧道(推荐,安全)'));
726
857
  console.log(colors.gray(' 在本地电脑执行以下命令,保持终端窗口打开:'));
727
- console.log(` ssh -N -L ${config.port}:127.0.0.1:${config.port} root@<服务器IP>`);
858
+ console.log(` ssh -N -L ${config.port}:127.0.0.1:${config.port} root@${ipDisplay}`);
728
859
  console.log(colors.gray(' 然后在本地浏览器访问:'));
729
860
  console.log(` ${colors.green(`http://127.0.0.1:${config.port}/?token=${config.token}`)}`);
730
861
  console.log('');
@@ -735,7 +866,7 @@ function showCompletionInfo(cliName, config, isVPS) {
735
866
  console.log(colors.gray(' - 腾讯云:安全组 → 入站规则 → 添加 TCP ' + config.port));
736
867
  console.log(colors.gray(' - 阿里云:安全组 → 配置规则 → 添加 TCP ' + config.port));
737
868
  console.log(colors.gray(' 3. 重启 Gateway:' + cliName + ' gateway restart'));
738
- console.log(colors.gray(' 4. 访问:http://<服务器IP>:' + config.port + '/?token=...'));
869
+ console.log(colors.gray(' 4. 访问:http://' + ipDisplay + ':' + config.port + '/?token=...'));
739
870
  } else {
740
871
  console.log(colors.cyan('\nDashboard 访问:'));
741
872
  console.log(` ${colors.yellow(`http://127.0.0.1:${config.port}/?token=${config.token}`)}`);
@@ -808,8 +939,10 @@ async function main() {
808
939
 
809
940
  // 检测是否在 VPS 上
810
941
  const isVPS = detectVPS();
942
+ const serverIP = isVPS ? await getPublicIP() : '';
811
943
  const port = existing.port || 18789;
812
944
  const token = existing.token || '<你的token>';
945
+ const ipDisplay = serverIP || '<服务器IP>';
813
946
 
814
947
  // 显示访问说明
815
948
  if (isVPS) {
@@ -818,7 +951,7 @@ async function main() {
818
951
  console.log('');
819
952
  console.log(colors.yellow(' 方式一:SSH 隧道(推荐,安全)'));
820
953
  console.log(colors.gray(' 在本地电脑执行以下命令,保持终端窗口打开:'));
821
- console.log(` ssh -N -L ${port}:127.0.0.1:${port} root@<服务器IP>`);
954
+ console.log(` ssh -N -L ${port}:127.0.0.1:${port} root@${ipDisplay}`);
822
955
  console.log(colors.gray(' 然后在本地浏览器访问:'));
823
956
  console.log(` ${colors.green(`http://127.0.0.1:${port}/?token=${token}`)}`);
824
957
  console.log('');
@@ -829,7 +962,7 @@ async function main() {
829
962
  console.log(colors.gray(' - 腾讯云:安全组 → 入站规则 → 添加 TCP ' + port));
830
963
  console.log(colors.gray(' - 阿里云:安全组 → 配置规则 → 添加 TCP ' + port));
831
964
  console.log(colors.gray(' 3. 重启 Gateway:' + existing.name + ' gateway restart'));
832
- console.log(colors.gray(' 4. 访问:http://<服务器IP>:' + port + '/?token=...'));
965
+ console.log(colors.gray(' 4. 访问:http://' + ipDisplay + ':' + port + '/?token=...'));
833
966
  } else {
834
967
  console.log(colors.cyan('\nDashboard 访问:'));
835
968
  console.log(` ${colors.yellow(`http://127.0.0.1:${port}/?token=${token}`)}`);
@@ -857,7 +990,7 @@ async function main() {
857
990
 
858
991
  if (options.reinstall) {
859
992
  const result = await uninstallAndReinstall(existing, options);
860
- showCompletionInfo(result.cliName, result.config, isVPS);
993
+ showCompletionInfo(result.cliName, result.config, isVPS, serverIP);
861
994
  process.exit(0);
862
995
  }
863
996
 
@@ -873,7 +1006,7 @@ async function main() {
873
1006
  process.exit(0);
874
1007
  } else if (choice === 'reinstall') {
875
1008
  const result = await uninstallAndReinstall(existing, options);
876
- showCompletionInfo(result.cliName, result.config, isVPS);
1009
+ showCompletionInfo(result.cliName, result.config, isVPS, serverIP);
877
1010
  process.exit(0);
878
1011
  } else {
879
1012
  console.log(colors.gray('\n已退出'));
@@ -903,7 +1036,8 @@ async function main() {
903
1036
 
904
1037
  // 检测是否在 VPS 上并显示完成信息
905
1038
  const isVPS = detectVPS();
906
- showCompletionInfo(cliName, config, isVPS);
1039
+ const serverIP = isVPS ? await getPublicIP() : '';
1040
+ showCompletionInfo(cliName, config, isVPS, serverIP);
907
1041
  }
908
1042
 
909
1043
  main().catch((e) => {
package/install.sh CHANGED
@@ -16,6 +16,115 @@ log_success() { echo -e "${GREEN}✓ $1${NC}"; }
16
16
  log_warn() { echo -e "${YELLOW}⚠ $1${NC}"; }
17
17
  log_error() { echo -e "${RED}✗ $1${NC}"; }
18
18
 
19
+ run_with_progress() {
20
+ local cmd="$1"
21
+ local message="$2"
22
+ local eta_total="$3"
23
+ local tty="/dev/tty"
24
+ local tmp=""
25
+
26
+ tmp=$(mktemp 2>/dev/null || mktemp -t openclawsetup 2>/dev/null || echo "/tmp/openclawsetup.$$")
27
+
28
+ local frames=('|' '/' '-' '\\')
29
+ local i=0
30
+ local start
31
+ start=$(date +%s)
32
+
33
+ bash -c "$cmd" >"$tmp" 2>&1 &
34
+ local pid=$!
35
+
36
+ while kill -0 "$pid" 2>/dev/null; do
37
+ local now
38
+ now=$(date +%s)
39
+ local seconds=$((now - start))
40
+ local frame="${frames[$i]}"
41
+ local suffix=""
42
+
43
+ if [[ "$eta_total" =~ ^[0-9]+$ ]] && [ "$eta_total" -gt 0 ]; then
44
+ local remaining=$((eta_total - seconds))
45
+ if [ "$remaining" -lt 0 ]; then
46
+ remaining=0
47
+ fi
48
+ suffix="(剩余约${remaining}s)"
49
+ else
50
+ suffix="(${seconds}s)"
51
+ fi
52
+
53
+ if [ -w "$tty" ]; then
54
+ printf "\r%s %s %s" "$frame" "$message" "$suffix" > "$tty"
55
+ else
56
+ printf "\r%s %s %s" "$frame" "$message" "$suffix" >&2
57
+ fi
58
+
59
+ i=$(( (i + 1) % 4 ))
60
+ sleep 0.1
61
+ done
62
+
63
+ local status
64
+ set +e
65
+ wait "$pid"
66
+ status=$?
67
+ set -e
68
+
69
+ if [ -w "$tty" ]; then
70
+ printf "\r%s\r" "$(printf '%*s' 80 '')" > "$tty"
71
+ else
72
+ printf "\r%s\r" "$(printf '%*s' 80 '')" >&2
73
+ fi
74
+
75
+ if [ $status -ne 0 ]; then
76
+ log_error "执行失败: $cmd"
77
+ if [ -s "$tmp" ]; then
78
+ echo -e "${YELLOW}---- 错误输出 (最后 20 行) ----${NC}" >&2
79
+ tail -n 20 "$tmp" >&2
80
+ fi
81
+ fi
82
+
83
+ rm -f "$tmp"
84
+ return $status
85
+ }
86
+
87
+ estimate_npm_eta() {
88
+ local pkg="$1"
89
+ local tarball=""
90
+ local size=""
91
+ local speed=""
92
+ local eta=""
93
+ local factor="2.0"
94
+ local overhead="8"
95
+
96
+ tarball=$(npm view "$pkg" dist.tarball 2>/dev/null | tail -n 1 || true)
97
+ size=$(npm view "$pkg" dist.size 2>/dev/null | tail -n 1 | tr -d '\r' || true)
98
+
99
+ if ! [[ "$size" =~ ^[0-9]+$ ]]; then
100
+ if [ -n "$tarball" ]; then
101
+ size=$(curl -sIL --connect-timeout 2 --max-time 4 "$tarball" 2>/dev/null \
102
+ | awk 'tolower($1)=="content-length:" {print $2}' \
103
+ | tail -n 1 | tr -d '\r' || true)
104
+ fi
105
+ fi
106
+
107
+ if ! [[ "$size" =~ ^[0-9]+$ ]]; then
108
+ return 1
109
+ fi
110
+
111
+ if [ -z "$tarball" ]; then
112
+ return 1
113
+ fi
114
+
115
+ speed=$(curl -L --range 0-262143 -o /dev/null -s -w '%{speed_download}' \
116
+ --connect-timeout 2 --max-time 6 "$tarball" || true)
117
+ speed=${speed%.*}
118
+
119
+ if ! [[ "$speed" =~ ^[0-9]+$ ]] || [ "$speed" -le 0 ]; then
120
+ return 1
121
+ fi
122
+
123
+ eta=$(awk -v size="$size" -v speed="$speed" -v factor="$factor" -v overhead="$overhead" \
124
+ 'BEGIN { est=(size*factor)/speed + overhead; if (est<5) est=5; printf "%d", est }')
125
+ echo "$eta"
126
+ }
127
+
19
128
  echo ""
20
129
  echo -e "${CYAN}🦞 OpenClaw 一键安装${NC}"
21
130
  echo ""
@@ -267,22 +376,54 @@ show_installed_info() {
267
376
 
268
377
  # 显示菜单并获取选择
269
378
  show_menu() {
270
- echo -e "${CYAN}\n请选择操作:${NC}"
271
- echo -e " ${YELLOW}1${NC}. 检查并更新 OpenClaw"
272
- echo -e " ${YELLOW}2${NC}. 卸载后重新安装(会清除配置)"
273
- echo -e " ${YELLOW}0${NC}. 退出"
274
- echo ""
275
-
276
- # /dev/tty 读取,支持管道环境
277
- if [ -t 0 ]; then
278
- # 标准输入是终端
279
- read -p "请输入选项编号: " choice
379
+ local choice=""
380
+ local tty="/dev/tty"
381
+
382
+ # 菜单输出到交互终端,避免被 $() 捕获或被管道吞掉
383
+ if [ -w "$tty" ]; then
384
+ printf "%b\n" "${CYAN}\n请选择操作:${NC}" > "$tty"
385
+ printf " %b1%b. 检查并更新 OpenClaw\n" "${YELLOW}" "${NC}" > "$tty"
386
+ printf " %b2%b. 卸载后重新安装(会清除配置)\n" "${YELLOW}" "${NC}" > "$tty"
387
+ printf " %b0%b. 退出\n\n" "${YELLOW}" "${NC}" > "$tty"
388
+ printf "请输入选项编号: " > "$tty"
389
+ read -r choice < "$tty" || true
280
390
  else
281
- # 管道环境,从 /dev/tty 读取
282
- echo -n "请输入选项编号: "
283
- read choice < /dev/tty
391
+ # 无交互终端时,输出到 stderr 并尝试从 stdin 读取
392
+ echo -e "${CYAN}\n请选择操作:${NC}" >&2
393
+ echo -e " ${YELLOW}1${NC}. 检查并更新 OpenClaw" >&2
394
+ echo -e " ${YELLOW}2${NC}. 卸载后重新安装(会清除配置)" >&2
395
+ echo -e " ${YELLOW}0${NC}. 退出" >&2
396
+ echo "" >&2
397
+ echo -n "请输入选项编号: " >&2
398
+ read -r choice || true
284
399
  fi
285
- echo "$choice"
400
+
401
+ MENU_CHOICE="$choice"
402
+ }
403
+
404
+ # 已安装场景:展示信息 + 菜单
405
+ installed_flow() {
406
+ while true; do
407
+ show_installed_info
408
+ show_menu
409
+
410
+ case "$MENU_CHOICE" in
411
+ 1)
412
+ update_openclaw
413
+ ;;
414
+ 2)
415
+ uninstall_openclaw
416
+ echo ""
417
+ echo -e "${GREEN}✓ 卸载完成,开始重新安装...${NC}"
418
+ echo ""
419
+ install_openclaw
420
+ ;;
421
+ *)
422
+ echo -e "\033[90m\n已退出\033[0m"
423
+ return 0
424
+ ;;
425
+ esac
426
+ done
286
427
  }
287
428
 
288
429
  # 更新 OpenClaw
@@ -295,7 +436,13 @@ update_openclaw() {
295
436
  echo " 当前版本: $current_version"
296
437
 
297
438
  log_info "正在更新到最新版本..."
298
- npm install -g ${cli_name}@latest
439
+ local eta=""
440
+ eta=$(estimate_npm_eta "${cli_name}@latest" || true)
441
+ if ! run_with_progress "npm install -g ${cli_name}@latest" "正在更新 ${cli_name}..." "$eta"; then
442
+ log_error "更新失败"
443
+ log_warn "可手动更新: npm install -g ${cli_name}@latest"
444
+ return 1
445
+ fi
299
446
 
300
447
  local new_version=$($cli_name --version 2>/dev/null || echo "未知")
301
448
  if [ "$new_version" != "$current_version" ]; then
@@ -324,7 +471,9 @@ uninstall_openclaw() {
324
471
 
325
472
  # 卸载 npm 包
326
473
  log_info "卸载 npm 包..."
327
- npm uninstall -g $cli_name 2>/dev/null || true
474
+ if ! run_with_progress "npm uninstall -g $cli_name" "正在卸载 ${cli_name}..."; then
475
+ log_warn "npm 卸载可能未完成"
476
+ fi
328
477
  log_success "npm 包已卸载"
329
478
 
330
479
  # 删除配置目录
@@ -393,26 +542,7 @@ main() {
393
542
  # 3. 检测 OpenClaw 安装状态
394
543
  if check_openclaw; then
395
544
  # 已安装:显示信息和菜单
396
- show_installed_info
397
-
398
- choice=$(show_menu)
399
-
400
- case "$choice" in
401
- 1)
402
- update_openclaw
403
- ;;
404
- 2)
405
- uninstall_openclaw
406
- echo ""
407
- echo -e "${GREEN}✓ 卸载完成,开始重新安装...${NC}"
408
- echo ""
409
- install_openclaw
410
- ;;
411
- *)
412
- echo -e "\033[90m\n已退出\033[0m"
413
- exit 0
414
- ;;
415
- esac
545
+ installed_flow
416
546
  else
417
547
  # 未安装:直接安装
418
548
  install_openclaw
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclawsetup",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "一键安装 OpenClaw - 自动完成基础部署,无需交互",
5
5
  "type": "module",
6
6
  "bin": {