Repository navigation
Expand file tree
/
Copy pathdeploy.sh
More file actions
executable file
·1714 lines (1601 loc) · 102 KB
/
Copy pathdeploy.sh
File metadata and controls
executable file
·1714 lines (1601 loc) · 102 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
# ============================================================================
# ProxCenter — 一键部署(构建前端 + 装后端依赖 + 生成 .env + 装 systemd 服务 + 自检)
#
# 与 start-prod.sh 的分工:
# start-prod.sh 把服务跑在**当前终端**里,适合临时验证、调试;
# deploy.sh 把它装成 **systemd 常驻服务**(开机自启、崩溃自动拉起),
# 并负责「构建产物 / 依赖 / 配置 / 数据库」这些一次性的准备工作。
#
# 三条设计原则(改动前请先读一遍):
# 1. 幂等:重复执行不破坏已有配置。backend/.env 已存在时**只**覆盖命令行显式
# 给出的键,绝不动其他值 —— 尤其是 SECRET_KEY,它是库里密文的加密根,
# 一旦被覆盖,已存库的 PVE Token / SMTP 口令全部解不开。
# 2. 不猜:数据库名、连接账号这类「缺了服务根本起不来」的参数,宁可提前报错
# 并给出可直接粘贴的命令,也不要先生成一份跑不通的配置。
# 3. 不越界:只写 <仓库>/ 与 /etc/systemd/system/<服务名>.service,
# 不改 Nginx、不动防火墙、不碰 MySQL 的其它库。HTTPS 与反代见 README。
# 唯一的例外在 [0/5] 步:**缺系统依赖时按发行版自动安装**(只装缺的那几个,
# 装完立刻验证;不想让它动系统就加 --skip-deps 退回「只检查不安装」)。
# 4. 不问:默认**全程不提问**,一条命令跑到底 —— 端口 / 库名 / 账号用默认值,
# SECRET_KEY 与各口令随机生成,装完把「面板地址 + 管理员口令 + 下一步」
# 一次打印清楚。需要自己指定的走命令行参数(--port / --db-*),需要逐项
# 确认的加 --reconfigure,两条路都不必让脚本停下等输入。
#
# 用法:
# sudo ./deploy.sh # 全自动:默认值 + 随机口令,装完打印账号口令
# sudo ./deploy.sh --reconfigure # 想自己定端口 / 库名 / 口令时用(逐项提问,回车即默认)
# sudo ./deploy.sh --port 9000 --service proxcenter --skip-frontend
# ./deploy.sh --no-systemd # 只准备环境与依赖(无需 root)
# ./deploy.sh --help
# ============================================================================
# 这个脚本用了 bash 专有语法(数组、$'\n'、local、read -s …)。Debian / Ubuntu 上
# /bin/sh 是 dash,直接 `sh deploy.sh` 会在中途报一堆莫名其妙的语法错;而
# RHEL 系的 sh 恰好是 bash,同一条命令却没事 —— 这种「换个发行版就炸」的坑要在
# 第一行拦住:发现当前 shell 不是 bash 就用 bash 重新执行自己。
if [ -z "${BASH_VERSION:-}" ]; then
if command -v bash >/dev/null 2>&1; then
exec bash "$0" "$@"
fi
echo "[x] 本脚本需要 bash(当前 shell 不是 bash,且系统里找不到 bash)。" >&2
echo " 请改用: bash $0 $*" >&2
exit 2
fi
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV="$ROOT/.venv"
ENV_FILE="$ROOT/backend/.env"
ENV_EXAMPLE="$ROOT/backend/.env.example"
# --- 参数(留空 = 未指定,绝不覆盖已有配置)---------------------------------
PORT=""
SERVICE="proxcenter"
RUN_USER="root"
SKIP_FRONTEND=0
USE_SYSTEMD=1
INSTALL_DEPS=1 # 0 = --skip-deps:只检查系统依赖,不安装
INSTALL_DB=1 # 0 = --no-install-db:本机没数据库也不代装(默认代装,做到零参数可部署)
RESET_DB_PASSWORD=0 # 1 = --reset-db-password:把已存在账号的口令对齐成 .env 里的值
ASSUME_YES=0 # 1 = --yes:不提问(现在这就是默认行为,保留参数兼容旧脚本)
RECONFIGURE=0 # 1 = --reconfigure:逐项提问一遍(默认值取自现有 .env,SECRET_KEY 不动)
INTERACTIVE=0 # 只有 --reconfigure 且有终端时才置 1,见下面的判定
ADMIN_PASSWORD_INPUT="" # 交互里填的初始管理员口令;留空则随机生成
DB_NAME="${DB_NAME:-}"
DB_USER="${DB_USER:-}"
DB_PASSWORD="${DB_PASSWORD:-}"
DB_HOST="${DB_HOST:-}"
DB_PORT="${DB_PORT:-}"
MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-}"
LOG_DIR="" # systemd 的日志目录;空 = 走 journal(见 [4/5] 里那段说明)
LANG_OPT=""
# --- 界面语言 -----------------------------------------------------------------
# 与前端(src/i18n)、后端(backend/app/i18n.py)**同一套约定**:
# 中文是源文案,英文译文放在下面的 MSG_EN 里查表,查不到就原样返回中文。
# 所以漏翻一条不会让脚本崩掉,只会在英文界面下继续显示中文 —— 与后端
# tr() 的兜底行为一致,不会出现「因为少一句译文就装不上」的情况。
#
# 优先级:--lang > PROXCENTER_LANG > $LC_ALL/$LC_MESSAGES/$LANG > 中文
LANG_UI="zh"
resolve_lang() {
local want="$LANG_OPT"
[ -n "$want" ] || want="${PROXCENTER_LANG:-}"
if [ -z "$want" ]; then
case "${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}" in
en|en_US|en_US.*|en_GB|en_GB.*) want="en" ;;
*) want="zh" ;;
esac
fi
case "$want" in
en|en-US|en_US|en-GB|en_GB|english) LANG_UI="en" ;;
*) LANG_UI="zh" ;;
esac
}
# 「挂着一个可交互的终端」吗?
#
# 刻意不写 `[ -r /dev/tty ]`:在**没有控制终端**的环境里(容器、cron、CI、
# 某些 IDE 的终端面板),/dev/tty 依然存在、ls 出来也是可读的 rwx,
# 可一 open 就失败并报 `No such device or address`。用 -r 判断会把这些环境
# 误判成「有终端」,于是该问的不问、不该问的乱问,输出也被搅乱。
#
# 所以这里真开一次:开得到才算数。失败时的 2>/dev/null 是必须的 ——
# 关着的 tty 会往 stderr 吐一行错误,而 set -e 下这行会直接终止脚本。
have_tty() {
# 放在子 shell 里试,且把 2>/dev/null 写在**外面**:
# `exec 3<>/dev/tty 2>/dev/null` 那种写法救不了 —— 重定向是按从左到右生效的,
# 3<> 先失败,2>/dev/null 还没来得及把 stderr 收走,shell 已经把
# 「No such device or address」打出去了。子 shell 里 fd 3 也天然不会漏给父 shell。
( exec 3<>/dev/tty ) 2>/dev/null
}
# 语言选择:只在「用户没指定语言 + 挂着终端」时问一次,然后照常往下自动装。
#
# 三条克制,是刻意加的:
# 1. 显式指定过(--lang / PROXCENTER_LANG)就不问 —— 用户已经说过了,再问是噪音;
# 2. 没有可交互终端就不问 —— cron / CI / 管道里提问会把部署直接卡死,
# 这条比「是否提示」重要得多;
# 3. 放在主解析循环**之后** —— --help 与未知参数要在解析阶段就说话,
# 提前插一句提问会让「看个帮助」也被问一遍。
# 读 /dev/tty 而不是 stdin:与下面的 ask() 同一套做法,stdout 被 tee 进日志、
# stdin 是管道时交互依然正常。
choose_lang() {
resolve_lang # 先按 --lang / 环境变量 / locale 定出默认值,供回车采纳
[ -n "$LANG_OPT" ] && return 0
[ -n "${PROXCENTER_LANG:-}" ] && return 0
have_tty || return 0
local reply="" tries=0
while [ "$tries" -lt 3 ]; do
tries=$((tries + 1))
# 提示语中英并列:此刻还没决定用哪种语言,只印一种的话,
# 另一种语言的用户只能靠数字猜。语言名也照原样写 ——
# 把「中文 / English」翻译成第三种语言反而更难认。
printf '\n 请选择界面语言 / Choose the interface language:\n' >&2
printf ' 1) 中文\n' >&2
printf ' 2) English\n' >&2
printf ' 序号(直接回车 = 按系统语言判定)/ Number (Enter = the system-locale default) [1]: ' >&2
reply=""
IFS= read -r reply < /dev/tty || reply=""
case "$reply" in
1 | zh | cn | 中文) LANG_UI="zh"; return 0 ;;
2 | en | English | english) LANG_UI="en"; return 0 ;;
'') return 0 ;; # 回车 = 保持默认
esac
printf ' 只能输入 1 或 2。/ Please enter 1 or 2.\n' >&2
done
# 连着三次都不认,按默认值继续 —— 不该为了界面语言把部署堵在这儿
return 0
}
declare -A MSG_EN=(
# ---- 启动横幅 ----
['本脚本需要 bash(当前 shell 不是 bash,且系统里找不到 bash)。']='This script needs bash (the current shell is not bash, and bash was not found on this system).'
['ProxCenter 部署']='ProxCenter deployment'
['部署完成']='Deployment complete'
['下一步:']='Next steps:'
# ---- 运行环境 ----
['检查运行环境']='Checking the environment'
['已指定 --skip-deps:只检查依赖、不自动安装']='--skip-deps given: checking dependencies only, nothing will be installed'
['没有找到 apt-get / dnf / yum / zypper / apk,无法自动安装依赖']='None of apt-get / dnf / yum / zypper / apk was found, cannot install dependencies automatically'
['当前不是 root 且没有 sudo,无法自动安装依赖(改用 sudo 重跑,或手工装)']='Not running as root and sudo is unavailable, cannot install dependencies automatically (re-run with sudo, or install them yourself)'
['未识别']='unrecognised'
['未知']='unknown'
['无']='none'
['<本机IP>']='<this host IP>'
['未知参数:%s(用 --help 看用法)']='Unknown option: %s (run --help for usage)'
# ---- 帮助文本(usage)----------------------------------------------------
['ProxCenter 一键部署']='ProxCenter one-click deployment'
['用法: sudo ./deploy.sh [选项]']='Usage: sudo ./deploy.sh [options]'
['选项:']='Options:'
['--port PORT|面板监听端口(默认沿用 .env,首次为 8080)']='--port PORT|Port the panel listens on (defaults to the value in .env, or 8080 on a first run)'
['--service NAME|systemd 服务名(默认 proxcenter)']='--service NAME|systemd service name (default: proxcenter)'
['--user USER|systemd 运行用户(默认 root)']='--user USER|User the systemd service runs as (default: root)'
['--skip-frontend|跳过前端构建,复用已有的 dist/(服务器上没有 Node.js 时用)']='--skip-frontend|Skip the frontend build and reuse the existing dist/ (for servers without Node.js)'
['--skip-deps|只检查系统依赖,不自动安装(离线 / 内网 / 想自己管依赖时用)']='--skip-deps|Only check system dependencies, install nothing (offline, intranet, or when you manage dependencies yourself)'
['--no-install-db|本机没有 MySQL/MariaDB 时也不代装(默认是代装的)']='--no-install-db|Do not install MySQL/MariaDB even if it is missing locally (it is installed by default)'
['--install-db|兼容旧用法,等于默认行为(保留参数,不再需要显式指定)']='--install-db|Kept for compatibility; this is already the default behaviour'
['--reset-db-password|把数据库账号的口令对齐成 .env 里的值(账号被别的应用共用时别用)']='--reset-db-password|Align the database account password with the value in .env (do not use it when the account is shared with another application)'
['-y, --yes|不提问,全部用默认值(**这就是默认行为**,保留参数以便旧脚本)']='-y, --yes|Ask nothing and use every default (**this is already the default**; kept so older scripts keep working)'
['--reconfigure|逐项提问一遍;默认值取自现有配置,回车即保持不变']='--reconfigure|Ask for each value; defaults come from the current configuration and pressing Enter keeps them'
['--no-systemd|只准备虚拟环境 / 依赖 / .env / 前端产物,不装服务(无需 root)']='--no-systemd|Only prepare the virtualenv, dependencies, .env and frontend build; install no service (no root needed)'
['--db-host HOST|数据库地址(默认 127.0.0.1)']='--db-host HOST|Database host (default: 127.0.0.1)'
['--db-port PORT|数据库端口(默认 3306)']='--db-port PORT|Database port (default: 3306)'
['--db-name NAME|面板数据库名(默认 proxcenter_panel)']='--db-name NAME|Database name for the panel (default: proxcenter_panel)'
['--db-user USER|面板数据库账号(默认 proxcenter)']='--db-user USER|Database user for the panel (default: proxcenter)'
['--db-password PASS|面板数据库口令(默认随机生成)']='--db-password PASS|Database password for the panel (generated randomly by default)'
['--mysql-root-password P|仅用于自动建库建号的 MySQL 管理员口令(选填)']='--mysql-root-password P|MySQL administrator password, used only to create the database and grant rights (optional)'
['--lang LANG|界面语言:zh 或 en(默认按系统语言判定)']='--lang LANG|Interface language: zh or en (defaults to the system locale)'
['-h, --help|显示本帮助']='-h, --help|Show this help'
['没指定语言时会先问一句']='When no language is given, the script asks first'
['直接在命令上给 --lang 时不会再问;没有终端(cron / CI / 管道)也不问,按系统语言($LC_ALL / $LANG)静默判定。']='It does not ask when --lang is given on the command line, nor when there is no terminal (cron / CI / a pipe); in that case the system locale ($LC_ALL / $LANG) decides silently.'
['环境变量同名可用:DB_NAME / DB_USER / DB_PASSWORD / DB_HOST / DB_PORT / MYSQL_ROOT_PASSWORD']='The same names also work as environment variables: DB_NAME / DB_USER / DB_PASSWORD / DB_HOST / DB_PORT / MYSQL_ROOT_PASSWORD'
['交互:']='Interactive mode:'
['**默认不提问。** sudo ./deploy.sh 会一条命令跑到底:端口用 8080、库名用
proxcenter_panel、账号用 proxcenter,SECRET_KEY / 数据库口令 / 管理员口令全部随机生成
(后端要求「首次建号口令」至少 12 位且不是常见弱口令,所以这里必须给随机强口令)。
装完最后一屏会把面板地址、管理员账号与初始口令打印出来 —— 口令只显示这一次,
请登录后立即在「个人中心 → 修改密码」里改掉。
要自己指定参数:加 --port / --db-* 等(仍然不提问);
要逐项确认:加 --reconfigure(默认值取自现有 backend/.env,回车即保持不变)。
重复执行只覆盖命令行显式给的值,不会动已有配置。
没有终端(cron / CI / 管道)时不提问,全部用默认值。']='**It asks nothing by default.** sudo ./deploy.sh runs all the way through in one command: port 8080, database
proxcenter_panel, user proxcenter, and randomly generated SECRET_KEY / database password /
administrator password (the backend requires the initial administrator password to be at
least 12 characters and not a common weak password, so it has to be a strong random one).
The last screen prints the panel address, the administrator account and the initial password —
the password is shown only once, so change it under "Profile → Change password" right after
signing in.
To choose values yourself, pass --port / --db-* and friends (still without questions);
to be asked about each one, add --reconfigure (defaults come from the existing backend/.env,
and pressing Enter keeps them).
Re-running only overwrites values given explicitly on the command line.
Without a terminal (cron / CI / a pipe) nothing is asked and every default is used.'
['示例:']='Examples:'
['# 最常见的用法:全自动一键部署(不提问;缺依赖就装,缺库就装 MariaDB,口令随机)']='# The common case: fully automatic (no questions; missing dependencies are installed, a missing database gets MariaDB, passwords are random)'
['# 想自己定端口 / 库名 / 口令:逐项提问,回车即用默认值']='# To pick the port / database name / password yourself: asked one by one, Enter accepts the default'
['# 数据库已经有人管(远程库 / 已有实例),只填面板要用的凭据']='# When the database is already managed elsewhere (remote host, existing instance), just supply the credentials'
['强口令']='a-strong-password'
['# 只更新代码后重新部署(复用已有 .env 与 dist)']='# To redeploy after a code update (reusing the existing .env and dist)'
['# 没有 root 权限 / 不想装服务:只把环境和依赖备好']='# Without root, or when you do not want a service: prepare the environment and dependencies only'
# ---- 交互提问(--reconfigure)--------------------------------------------
['回车保留已设置的值']='press Enter to keep the value already set'
['面板监听端口']='Port the panel listens on'
['数据库名']='Database name'
['数据库账号']='Database user'
['数据库口令(留空 = 随机生成)']='Database password (empty = generate a random one)'
['MySQL 管理员口令(留空 = 试本机免密凭据;只用于建库授权)']='MySQL administrator password (empty = try the local passwordless credentials; only used to grant rights)'
['面板初始管理员口令(admin 用;留空 = 随机生成)']='Initial password for the panel administrator (used by admin; empty = generate a random one)'
['面板管理员口令(留空 = 不改)']='Panel administrator password (empty = leave it unchanged)'
['本机没有数据库时自动安装 MariaDB 并启动']='Install and start MariaDB automatically when this machine has no database'
['面板管理员(admin)口令(至少 12 位;留空 = 随机生成)']='Password for the panel administrator (admin), at least 12 characters (empty = generate a random one)'
# ---- 故障排查用的「标签|命令」对照表(print_hint_list)--------------------
# 「装 python3」只在新发行版上等于 3.11+:Debian 12 是 3.11、Ubuntu 24.04 是
# 3.12,而 Debian 11 是 3.9、Ubuntu 20.04/22.04 是 3.9/3.10。旧发行版必须单独
# 给办法,否则用户照提示装完还是低版本,面板要么装不上、要么跑在半残状态。
['Debian 12+ / Ubuntu 24.04+|sudo apt install python3 python3-venv']='Debian 12+ / Ubuntu 24.04+|sudo apt install python3 python3-venv'
['Ubuntu 20.04 / 22.04(自带 3.9 / 3.10)|sudo add-apt-repository ppa:deadsnakes/ppa && sudo apt install python3.11 python3.11-venv']='Ubuntu 20.04 / 22.04 (ship 3.9 / 3.10)|sudo add-apt-repository ppa:deadsnakes/ppa && sudo apt install python3.11 python3.11-venv'
['Debian 11(自带 3.9)|建议升级到 Debian 12,或用 pyenv / 源码装 3.11 后用 PYTHON_BIN 指定']='Debian 11 (ships 3.9)|upgrade to Debian 12, or build/install 3.11 via pyenv or from source and point PYTHON_BIN at it'
['RHEL 8 / CentOS 8|sudo dnf module enable python311 && sudo dnf install python3.11 python3.11-pip']='RHEL 8 / CentOS 8|sudo dnf module enable python311 && sudo dnf install python3.11 python3.11-pip'
['RHEL 9 / TencentOS 4 / Rocky / Alma|sudo dnf install python3.11']='RHEL 9 / TencentOS 4 / Rocky / Alma|sudo dnf install python3.11'
['其它发行版|用系统包管理器装 python3 ≥ 3.11']='Other distributions|install python3 ≥ 3.11 with your package manager'
['Debian / Ubuntu|sudo apt install python3-venv']='Debian / Ubuntu|sudo apt install python3-venv'
['RHEL 系|sudo dnf install python3-pip']='RHEL family|sudo dnf install python3-pip'
# ---- 故障排查的长提示 ----------------------------------------------------
['未找到 Python 3.11+,且自动安装没成功。请手工装一个再重跑:']='No Python 3.11+ was found and installing it automatically did not work. Install one and re-run:'
['装好后也可以指定路径重跑:PYTHON_BIN=/usr/bin/python3.11 ./deploy.sh ...']='Once installed you can also point at it explicitly: PYTHON_BIN=/usr/bin/python3.11 ./deploy.sh ...'
['无法创建虚拟环境。请手工安装 venv 支持后重跑:']='Could not create the virtualenv. Install venv support and re-run:'
['后端依赖安装失败。请检查网络,或改用镜像源:']='Installing the backend dependencies failed. Check the network, or use a mirror:'
['自动建库失败,请手工执行下面这段 SQL:']='Automatic database creation failed; run this SQL yourself:'
['backend/.env 里的 DB_PASSWORD']='DB_PASSWORD in backend/.env'
['连不上数据库的管理员账号(试过 --mysql-root-password / 免密 root / /etc/mysql/debian.cnf / ~/.my.cnf),跳过自动建库。手工建库命令:']='Could not connect to the database as administrator (tried --mysql-root-password / passwordless root / /etc/mysql/debian.cnf / ~/.my.cnf), so automatic creation is skipped. Commands to create it yourself:'
["给管理员口令也行:sudo ./deploy.sh --mysql-root-password '<root口令>'"]="You can also supply the administrator password: sudo ./deploy.sh --mysql-root-password '<root password>'"
['backend/.env 里的 SECRET_KEY 太短或仍是占位值,后端会拒绝启动。']='SECRET_KEY in backend/.env is too short or still a placeholder, and the backend will refuse to start.'
['它既是登录 JWT 的签名密钥,也是库里密文的加密根 ——']='It is both the signing key for login JWTs and the encryption root for the secrets stored in the database —'
['· 全新安装:删掉 .env 里那一行(或删掉整个 .env 重新生成)再重跑即可;']='· fresh install: delete that line from .env (or delete .env entirely to regenerate it) and re-run;'
['· 已有数据:换掉它会让已存的密文(PVE Token / SMTP 口令)全部解不开,确认清楚再改:']='· existing data: replacing it makes every stored secret (PVE tokens, SMTP passwords) undecryptable — change it only if you are sure:'
['backend/.env 里的 ADMIN_PASSWORD 偏弱,但库里已有管理员(改它不影响现有登录);以后若要重建账号,请先换成至少 12 位的随机口令。']='ADMIN_PASSWORD in backend/.env is weak, but the database already has an administrator (changing it does not affect existing sign-ins); replace it with a random password of at least 12 characters before recreating any account.'
# ---- Python ----
['本机没有 Python 3.11+,尝试安装']='No Python 3.11+ on this machine, trying to install it'
['尝试启用 python311 模块流(RHEL 8 系需要)']='Trying to enable the python311 module stream (needed on RHEL 8)'
# ---- Node.js ----
['缺少可用的 Node.js 22.22+,且当前条件下不能自动安装(--skip-deps / 无包管理器 / 无 root)']='No usable Node.js 22.22+, and it cannot be installed automatically under the current conditions (--skip-deps / no package manager / not root)'
['本机没有可用的 Node.js 22.22+,尝试安装']='No usable Node.js 22.22+ on this machine, trying to install it'
['发行版仓库的 nodejs 通常低于 22.22,这一步多半装不上;RHEL 9 试一下 nodejs:22 模块流']='Distro repos usually ship a nodejs below 22.22, so this step will most likely fall short; on RHEL 9 we try the nodejs:22 module stream'
['尝试启用 nodejs:22 模块流(RHEL 8 没有这个流,会失败但不影响后续)']='Trying to enable the nodejs:22 module stream (RHEL 8 has no such stream; it fails harmlessly and we move on)'
['发行版仓库给不到 Node.js 22.22+,改用官方二进制(装到 .node/,不碰系统)']='The distro repo cannot provide Node.js 22.22+, so we fall back to the official binaries (unpacked into .node/, system untouched)'
['自动安装 Node.js 失败,继续按下面的提示手工处理']='Automatic Node.js installation failed; use the manual options below'
['不支持的 CPU 架构(%s),没法自动下载 Node.js']='Unsupported CPU architecture (%s); cannot download Node.js automatically'
['当前系统不是 glibc(Alpine/musl 这类),官方二进制跑不起来,需要从发行版仓库装 Node.js 22.22+']='This system is not glibc-based (Alpine/musl and friends), so the official binaries will not run; install Node.js 22.22+ from the distro repo instead'
['查不到可下载的 Node.js 版本(网络不通或镜像不可用)']='Could not determine a downloadable Node.js version (network down or mirrors unavailable)'
['下载 Node.js %s:%s']='Downloading Node.js %s: %s'
['这个源不行,换下一个:%s']='That mirror did not work, trying the next one: %s'
['解压到 %s']='Unpacking into %s'
['构建:npm run build → dist/']='Building: npm run build → dist/'
# ---- 虚拟环境与依赖 ----
['[1/5] 准备后端虚拟环境与依赖']='[1/5] Preparing the backend virtualenv and dependencies'
['补装 venv 相关包后创建成功']='Created successfully after installing the venv packages'
['安装 backend/requirements.txt(已装则跳过)']='Installing backend/requirements.txt (skipped if already satisfied)'
['依赖安装失败,尝试补装编译工具后重试(可能是缺预编译轮子的架构)']='Dependency installation failed, retrying with build tools installed (this architecture may lack prebuilt wheels)'
['补装编译工具后安装成功']='Installed successfully after adding the build tools'
# ---- 配置 ----
['[2/5] 准备后端配置']='[2/5] Preparing the backend configuration'
['已从 .env.example 生成 backend/.env']='Generated backend/.env from .env.example'
['backend/.env 已存在,保留现有值(只覆盖命令行显式指定的项)']='backend/.env already exists, keeping the current values (only options given on the command line are overwritten)'
['(--reconfigure)默认值取自现有 backend/.env,回车即保持不变;']='(--reconfigure) Defaults come from the existing backend/.env; pressing Enter keeps them unchanged;'
['口令留空则沿用(不会被换成新生成的)。SECRET_KEY 不动。']='leaving a password empty keeps the current one (it is never replaced with a freshly generated value). SECRET_KEY is left alone.'
['下面几项可以直接回车用默认值(口令留空 = 自动生成随机值):']='You can press Enter to accept the defaults below (an empty password means a random one is generated):'
['数据库凭据缺失,已自动生成并写入 backend/.env(口令随机生成,不回显)']='Database credentials were missing; they have been generated and written to backend/.env (passwords are random and never echoed)'
['--no-systemd:继续执行,但这样生成的配置装成服务后起不来。']='--no-systemd: continuing, but a service installed from this configuration will not start.'
# ---- 数据库 ----
['本机没有 systemd,请手工启动 MariaDB 后重跑']='This machine has no systemd; start MariaDB yourself and re-run'
['自动安装 MariaDB 失败,请手工装好数据库后重跑']='Automatic MariaDB installation failed; install the database yourself and re-run'
['本机没有数据库在跑,且指定了 --no-install-db:请手工准备']='No database is running locally and --no-install-db was given: please prepare one yourself'
['已用面板账号连库验证通过']='Verified by connecting to the database with the panel account'
['已生成随机 SECRET_KEY']='Generated a random SECRET_KEY'
['backend/.env 里的 ADMIN_PASSWORD 为空或太弱,而库里还没有管理员 ——']='ADMIN_PASSWORD in backend/.env is empty or too weak, and the database has no administrator yet —'
['后端会以「首次建号口令过弱」为由拒绝启动(至少 12 位,且不能是常见弱口令)。']='the backend will refuse to start because the initial password is too weak (at least 12 characters, and not a common weak password).'
['已自动生成面板管理员(admin)的初始口令(装完在结尾打印一次)']='Generated the initial password for the panel administrator (admin) (printed once at the end)'
['已写入 backend/.env 的 ADMIN_PASSWORD']='Wrote ADMIN_PASSWORD to backend/.env'
['太短或太常见,请再试一次(至少 12 位)']='Too short or too common, please try again (at least 12 characters)'
['配置预检通过']='Configuration pre-check passed'
['后端拒绝了这份配置(原文如下):']='The backend rejected this configuration (message below):'
# ---- 前端构建 ----
['[3/5] 准备前端构建产物']='[3/5] Preparing the frontend build'
['跳过构建,复用已有 dist/']='Skipping the build, reusing the existing dist/'
['安装前端依赖(已满足时很快)']='Installing frontend dependencies (quick when already satisfied)'
# ---- systemd ----
['[4/5] 跳过 systemd(--no-systemd)']='[4/5] Skipping systemd (--no-systemd)'
['服务已启动并设为开机自启']='Service started and enabled at boot'
# ---- 健康检查 ----
['[5/5] 健康检查']='[5/5] Health check'
['未安装服务,跳过(起服务后自行访问 /api/health 即可)']='No service installed, skipping (once it runs, visit /api/health yourself)'
['健康检查未通过(服务没起来,或在反复重启)。先把最近的日志抓出来:']='Health check failed (the service did not start, or it is restarting repeatedly). Here are the most recent logs:'
['怎么读这几段:']='How to read these:'
[' ·「面板启动被拒绝」→ .env 配置问题,后面那句已写明改哪个键']=' · "Panel startup was rejected" → a backend/.env problem; the message names the key to change'
[' · Access denied for user → 数据库账号/授权不对(带 --db-* 重跑可改写 .env)']=' · Access denied for user → wrong database account or grants (re-run with --db-* to rewrite .env)'
[" · Can't connect to MySQL → 数据库没起(本机:systemctl status mariadb)"]=" · Can't connect to MySQL → the database is not running (locally: systemctl status mariadb)"
[' · ModuleNotFoundError / 找不到 app → 依赖没装好,去掉 --skip-deps 重跑']=' · ModuleNotFoundError / app not found → dependencies are incomplete; re-run without --skip-deps'
# ---- 结尾提示 ----
['★ 初始管理员口令(只显示这一次):']='★ Initial administrator password (shown only this once):'
['请立即登录后改掉:右上角用户名 →「个人中心」→ 修改密码。']='Change it right after signing in: top-right username → "Profile" → Change password.'
['(管理员也可以去「设置 → 用户管理」给自己重置密码。)']='(Administrators can also reset it under "Settings → Users".)'
['库里已有管理员时,本次只改了 backend/.env,现有登录口令不变。']='When the database already has an administrator, only backend/.env was changed and existing passwords still work.'
['1) 用上面的账号登录,先改掉初始口令;']='1) Sign in with the account above and change the initial password;'
['2) 在「设置 → Proxmox 连接配置」填入 PVE 地址与 API Token;']='2) Add the PVE address and API token under "Settings → Proxmox connection";'
['3) 公网访问:按 README「用 Nginx 上 HTTPS」配好反代与证书,再在']='3) For public access, set up the reverse proxy and certificate as described in the README ("put it behind Nginx with HTTPS"), then'
# ---- 带变量的整句(tf)----------------------------------------------------
# 键是中文模板,%s 按「含义」占位;英文可以自己决定语序。
[' 仓库目录 : %s']=' Repository : %s'
[' 服务名 : %s']=' Service : %s'
['系统:%s|包管理器:%s']='OS: %s | package manager: %s'
['本系统(%s)不认识「%s」对应的包,跳过']='This system (%s) has no package for "%s"; skipping'
['安装:%s']='Installing: %s'
['缺少「%s」,且当前条件下不能自动安装(--skip-deps / 无包管理器 / 无 root)']='Missing "%s", and it cannot be installed automatically under the current conditions (--skip-deps / no package manager / not root)'
['Node.js:%s / npm:%s']='Node.js: %s / npm: %s'
['Python:%s(%s)']='Python: %s (%s)'
['[4/5] 安装 systemd 服务:%s']='[4/5] Installing the systemd service: %s'
['创建虚拟环境:%s']='Creating the virtualenv: %s'
['创建虚拟环境失败:%s']='Failed to create the virtualenv: %s'
['本机 %s:%s 没有数据库在跑,安装 MariaDB']='No database is running on %s:%s, installing MariaDB'
['启动并设为开机自启:%s']='Enabling and starting %s'
['创建数据库 %s 与账号 %s(管理员连接方式:%s)']='Creating database %s and account %s (admin connection method: %s)'
['(--reset-db-password)把 %s 的口令对齐成 %s 里的值']='(--reset-db-password) aligning the password of %s with the value in %s'
['数据库不可达(%s:%s)或没有 mysql 客户端,跳过自动建库']='Database unreachable (%s:%s) or no mysql client available, skipping automatic database creation'
['MySQL 可达:%s:%s / 库 %s']='MySQL reachable: %s:%s / database %s'
['手动启动:%s']='Start manually: %s'
['[4/5] 安装 systemd 服务:%s']='[4/5] Installing the systemd service: %s'
['服务将以 %s 运行,请确认该用户能读 %s(含 .venv / dist / backend/.env)。']='The service will run as %s; make sure that user can read %s (including .venv / dist / backend/.env).'
['已写入 %s']='Wrote %s'
['面板已就绪:%s']='Panel is ready: %s'
['---- journalctl -u %s(最近 40 行)----']='---- journalctl -u %s (last 40 lines)----'
['---- %s/panel.log(最近 40 行)----']='---- %s/panel.log (last 40 lines)----'
[' · Address already in use → %s 被占用,用 --port 换一个']=' · Address already in use → port %s is already taken, pick another one with --port'
[' 持续跟踪: journalctl -u %s -f']=' Follow along: journalctl -u %s -f'
[' 面板地址 : http://%s:%s']=' Panel : http://%s:%s'
[' 注意 : firewalld 没放通 %s/tcp,从别的机器访问会失败;放通: firewall-cmd --permanent --add-port=%s/tcp && firewall-cmd --reload']=' Note : firewalld does not allow %s/tcp, so other machines cannot reach the panel; open it with: firewall-cmd --permanent --add-port=%s/tcp && firewall-cmd --reload'
[' 注意 : ufw 没放通 %s/tcp,从别的机器访问会失败;放通: ufw allow %s/tcp']=' Note : ufw does not allow %s/tcp, so other machines cannot reach the panel; open it with: ufw allow %s/tcp'
[' 登录账号 : %s']=' Sign in as : %s'
[' API 文档 : 默认关闭(需要时在 .env 设 API_DOCS_ENABLED=true,且须管理员登录)']=' API docs : off by default (set API_DOCS_ENABLED=true in .env; admin sign-in required)'
[' 数据库 : %s @ %s:%s(账号 %s)']=' Database : %s @ %s:%s (account %s)'
[' 配置文件 : %s ← 数据库口令在里面(自动生成,不回显)']=' Config : %s ← the database password lives here (generated, never echoed)'
[' 服务管理 : systemctl {status|restart|stop} %s']=' Service : systemctl {status|restart|stop} %s'
[' 日志 : journalctl -u %s -f 或 tail -f %s/panel.log']=' Logs : journalctl -u %s -f or tail -f %s/panel.log'
[' 日志 : journalctl -u %s -f']=' Logs : journalctl -u %s -f'
['无法创建日志目录 %s,改用 journal 输出(journalctl -u %s -f 查看)']='Cannot create the log directory %s; falling back to journal output (view with: journalctl -u %s -f)'
['systemd 打不开日志文件(status=209/STDOUT,常见于 SELinux 限制),改用 journal 输出']='systemd cannot open the log file (status=209/STDOUT, usually an SELinux restriction); falling back to journal output'
[' backend/.env 里设 FORCE_HTTPS=true 并 systemctl restart %s。']=' set FORCE_HTTPS=true in backend/.env and run systemctl restart %s.'
['安装 systemd 服务需要 root:请用 sudo 执行,或加 --no-systemd 只准备环境。']='Installing the systemd service requires root: run with sudo, or add --no-systemd to only prepare the environment.'
['本机没有 systemd(systemctl 不存在),请改用 --no-systemd。']='This machine has no systemd (systemctl not found); use --no-systemd instead.'
['apt-get update 失败,继续尝试安装(离线环境属正常)']='apt-get update failed; still trying to install (normal in offline environments)'
['没装字体,图形验证码将使用 Pillow 内置字体(能看清,但不如系统字体好看)']='No font package installed; the captcha will use Pillow built-in font (readable, but less pretty than a system font)'
['缺少 %s,仓库不完整(建议重新 git clone 后再执行)。']='Missing %s; the repository is incomplete (re-clone it with git and run again).'
['缺少模板 %s,仓库不完整。']='Missing the template %s; the repository is incomplete.'
['pip 自身升级失败,继续用现有版本']='Upgrading pip itself failed; continuing with the current version'
['装 MySQL 客户端失败,稍后跳过自动建库']='Installing the MySQL client failed; automatic database creation will be skipped later'
['启动 %s 失败,请手工检查:systemctl status %s']='Failed to start %s; check manually with: systemctl status %s'
['MariaDB 装好但端口还没起来(初始化可能仍在进行),稍后自检会再确认']='MariaDB is installed but the port is not up yet (initialization may still be running); the later self-check will confirm'
['--skip-frontend 要求 dist/index.html 已存在,请先构建或去掉该参数。']='--skip-frontend requires dist/index.html to exist; build it first or drop that option.'
['构建结束但 dist/index.html 不存在,请检查上面的构建输出。']='The build finished but dist/index.html is missing; check the build output above.'
['指定的运行用户 %s 不存在。']='The specified run user %s does not exist.'
['请按上面那句话修 %s 后重跑本脚本。']='Fix %s as described above, then re-run this script.'
['未知系统']='unknown system'
['管理面板']='management panel'
# ---- 多行的整句:单引号串可以跨行,直接原样保留原来的排版 ----------
['用面板账号连不上数据库:%s@%s:%s 库 %s
(服务装上后会一直重启:Access denied for user)。两种成因与对策:
① 账号早已存在且口令与 .env 里的不同 —— CREATE USER IF NOT EXISTS 不会改口令,
而 .env 的口令可能被重新生成过(删过 .env 再重跑)。该账号没被别的应用共用时:
sudo ./deploy.sh --reset-db-password
② .env 里 DB_PASSWORD 的写法让解析截断了(值里有 # 空格 或引号时 dotenv 会截断)。
对比一下两边读到的长度:
%s
%s']='Cannot connect to the database with the panel account: %s@%s:%s, database %s
(the service would restart forever with: Access denied for user). Two likely causes and fixes:
① the account already exists with a different password — CREATE USER IF NOT EXISTS does not change it,
while .env may have been regenerated (deleted .env and re-run). If no other app shares this account:
sudo ./deploy.sh --reset-db-password
② the DB_PASSWORD value in .env got truncated while parsing (dotenv truncates values containing #, spaces or quotes).
compare the lengths read on both sides:
%s
%s'
['ADMIN_PASSWORD 仍不满足强度要求,后端会拒绝启动。请手工在 %s 里
设一个至少 12 位的新口令后重跑;生成一个可用:
%s']='ADMIN_PASSWORD still does not meet the strength requirement, so the backend will refuse to start. Set a new password of at least 12 characters in %s by hand, then re-run; you can generate one with:
%s'
['backend/.env 里读不到数据库配置(DB_NAME / DB_USER)。正常情况下本脚本会
自动生成并写入 %s —— 出现这句通常是文件不可读或键名被改坏了。
也可以直接显式指定后重跑:
sudo ./deploy.sh --db-name proxcenter_panel --db-user proxcenter --db-password %s']='Cannot read the database settings (DB_NAME / DB_USER) from backend/.env. Normally this script generates them and writes them to %s — this message usually means the file is unreadable or the keys were renamed.
You can also specify them explicitly and re-run:
sudo ./deploy.sh --db-name proxcenter_panel --db-user proxcenter --db-password %s'
['连不上 MySQL(%s:%s),服务即使装上也会一直重启。请确认:
· 数据库已启动(本机:systemctl status mariadb / mysqld)
· 地址与端口正确、防火墙已放通
· 本机有库但没起来时,去掉 --no-install-db 重跑,脚本会尝试安装并启动它']='Cannot connect to MySQL (%s:%s); the service would restart forever even after installation. Please check:
· the database is running (locally: systemctl status mariadb / mysqld)
· the address and port are correct and the firewall allows them
· if a local database exists but is not running, re-run without --no-install-db and the script will try to install and start it'
['未找到可用的 Node.js 22.22+,无法构建前端(当前:%s)。三种选择:
① 自己装一个(发行版仓库的 nodejs 通常只有 18/20,得用第三方源):
Debian/Ubuntu:curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt install -y nodejs
RHEL 9:dnf module enable nodejs:22 && dnf install -y nodejs npm
nvm:nvm install 22 && nvm use 22
装完 node -v 确认是 v22.22 及以上再重跑本脚本;
② 在装有 Node.js 22.22+ 的机器上执行 npm install && npm run build,把 dist/ 拷到 %s/ 后加 --skip-frontend 重跑;
③ 改用 Docker 部署:镜像里的 dist/ 已经构建好了,宿主机不需要 Node.js。']='No usable Node.js 22.22+ was found, so the frontend cannot be built (currently: %s). Three options:
① install one yourself (distro repos usually ship only 18/20 — a third-party source is required):
Debian/Ubuntu: curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt install -y nodejs
RHEL 9: dnf module enable nodejs:22 && dnf install -y nodejs npm
nvm: nvm install 22 && nvm use 22
run node -v afterwards to confirm it is v22.22 or newer, then re-run this script;
② run npm install && npm run build on a machine that has Node.js 22.22+, copy dist/ to %s/, then re-run with --skip-frontend;
③ switch to Docker: the image already contains a built dist/, so the host needs no Node.js.'
)
# t:整句查表。英文模式下查不到就原样返回中文(与后端 tr() 同一套兜底)。
t() {
if [ "$LANG_UI" = "zh" ]; then printf '%s' "$1"; return 0; fi
local out="${MSG_EN["$1"]-}"
[ -n "$out" ] || out="$1"
printf '%s' "$out"
}
# tf:带变量的整句。中文模板作为译表键,参数按「含义」顺序传入;
# 两种语言各自决定 %s 的顺序,所以英文可以自由调整语序而不用改调用点。
tf() {
local zh="$1"; shift
local fmt="$zh"
[ "$LANG_UI" = "zh" ] || fmt="${MSG_EN["$zh"]-$zh}"
# `--` 不能省:帮助文本里有以「--db-host」开头的整句,printf 会把格式串当成
# 选项解析,直接报 `printf: --: invalid option`(openEuler 上装完就在这一行炸)。
printf -- "$fmt" "$@"
}
# --- 输出helper --------------------------------------------------------------
# 这四个是全脚本唯一的输出出口,所以翻译放在这里做 —— 纯文案调用点
# 一行都不用改,只有内嵌变量的句子才需要显式走 tf。
step() { echo; echo "==> $(t "$*")"; }
info() { echo " $(t "$*")"; }
warn() { echo " [!] $(t "$*")" >&2; }
die() { echo "[x] $(t "$*")" >&2; exit 1; }
# 故障排查用的「标签 : 命令」对照表。查表用的是**整行**(标签|命令),
# 译表里也存整行 —— 与 usage() 的选项表同一套做法:键天然唯一,
# 标签与命令的顺序/断行两种语言可以各自决定,命令本身两边一致、可直接复制。
# 只在失败路径用,所以一律写 stderr。
print_hint_list() {
local line translated
while IFS= read -r line; do
[ -n "${line:-}" ] || continue
translated="$(t "$line")"
printf ' %-40s %s\n' "${translated%%|*}" "${translated#*|}" >&2
done
return 0
}
usage() {
resolve_lang
printf '%s\n\n' "$(t 'ProxCenter 一键部署')"
printf '%s\n\n' "$(t '用法: sudo ./deploy.sh [选项]')"
printf '%s\n' "$(t '选项:')"
# 选项表:左边参数、右边说明。查表用的是**整行**(参数 + 说明),译表里存的
# 也是整行 —— 键因此天然唯一(两个选项的说明文字撞车也不会互相覆盖)。
# 于是选项列表只有这一份:加一条就是加一行,中英文说明都在译表里,不会漏。
# 宽度固定 26 列:两个语言的参数名都是 ASCII,列宽自然对齐。
while IFS= read -r optline; do
[ -n "${optline:-}" ] || continue
# 先整行翻译,再按第一个 | 拆开排版:说明的语序/长度两种语言可以各自决定,
# 而参数名与列宽仍然对齐。
__line="$(t "$optline")"
printf ' %-26s %s\n' "${__line%%|*}" "${__line#*|}"
done <<'OPTS'
--port PORT|面板监听端口(默认沿用 .env,首次为 8080)
--service NAME|systemd 服务名(默认 proxcenter)
--user USER|systemd 运行用户(默认 root)
--skip-frontend|跳过前端构建,复用已有的 dist/(服务器上没有 Node.js 时用)
--skip-deps|只检查系统依赖,不自动安装(离线 / 内网 / 想自己管依赖时用)
--no-install-db|本机没有 MySQL/MariaDB 时也不代装(默认是代装的)
--install-db|兼容旧用法,等于默认行为(保留参数,不再需要显式指定)
--reset-db-password|把数据库账号的口令对齐成 .env 里的值(账号被别的应用共用时别用)
-y, --yes|不提问,全部用默认值(**这就是默认行为**,保留参数以便旧脚本)
--reconfigure|逐项提问一遍;默认值取自现有配置,回车即保持不变
--no-systemd|只准备虚拟环境 / 依赖 / .env / 前端产物,不装服务(无需 root)
--db-host HOST|数据库地址(默认 127.0.0.1)
--db-port PORT|数据库端口(默认 3306)
--db-name NAME|面板数据库名(默认 proxcenter_panel)
--db-user USER|面板数据库账号(默认 proxcenter)
--db-password PASS|面板数据库口令(默认随机生成)
--mysql-root-password P|仅用于自动建库建号的 MySQL 管理员口令(选填)
--lang LANG|界面语言:zh 或 en(默认按系统语言判定)
-h, --help|显示本帮助
OPTS
printf '\n%s\n' "$(t '环境变量同名可用:DB_NAME / DB_USER / DB_PASSWORD / DB_HOST / DB_PORT / MYSQL_ROOT_PASSWORD')"
printf '\n%s\n' "$(t '交互:')"
printf '%s\n' "$(t '**默认不提问。** sudo ./deploy.sh 会一条命令跑到底:端口用 8080、库名用
proxcenter_panel、账号用 proxcenter,SECRET_KEY / 数据库口令 / 管理员口令全部随机生成
(后端要求「首次建号口令」至少 12 位且不是常见弱口令,所以这里必须给随机强口令)。
装完最后一屏会把面板地址、管理员账号与初始口令打印出来 —— 口令只显示这一次,
请登录后立即在「个人中心 → 修改密码」里改掉。
要自己指定参数:加 --port / --db-* 等(仍然不提问);
要逐项确认:加 --reconfigure(默认值取自现有 backend/.env,回车即保持不变)。
重复执行只覆盖命令行显式给的值,不会动已有配置。
没有终端(cron / CI / 管道)时不提问,全部用默认值。')"
printf ' %s\n' "$(t '没指定语言时会先问一句')"
printf ' %s\n' "$(t '直接在命令上给 --lang 时不会再问;没有终端(cron / CI / 管道)也不问,按系统语言($LC_ALL / $LANG)静默判定。')"
printf '\n%s\n' "$(t '示例:')"
# 命令本身与语言无关,只有注释要翻译。
printf ' %s\n' "$(t '# 最常见的用法:全自动一键部署(不提问;缺依赖就装,缺库就装 MariaDB,口令随机)')"
printf ' sudo ./deploy.sh\n'
printf '\n %s\n' "$(t '# 想自己定端口 / 库名 / 口令:逐项提问,回车即用默认值')"
printf ' sudo ./deploy.sh --reconfigure\n'
printf '\n %s\n' "$(t '# 数据库已经有人管(远程库 / 已有实例),只填面板要用的凭据')"
printf " sudo ./deploy.sh --db-host 10.0.0.9 --db-name proxcenter_panel --db-user proxcenter --db-password '%s'\n" \
"$(t '强口令')"
printf '\n %s\n' "$(t '# 只更新代码后重新部署(复用已有 .env 与 dist)')"
printf ' sudo ./deploy.sh --skip-frontend\n'
printf '\n %s\n' "$(t '# 没有 root 权限 / 不想装服务:只把环境和依赖备好')"
printf ' ./deploy.sh --no-systemd\n'
printf '\n'
}
# 预扫一遍 --lang:--help 与「未知参数」都发生在主解析循环里那时 LANG_OPT
# 还没赋值,所以这里先取一次语言,否则「英文用户跑 --help 看中文帮助」没意义。
# 只认 --lang X 与 --lang=X 两种写法;认错了最坏结果是回落中文,不会误事。
__prev=""
for __a in "$@"; do
if [ "$__prev" = "--lang" ]; then LANG_OPT="$__a"; break; fi
case "$__a" in
--lang=*) LANG_OPT="${__a#--lang=}"; break ;;
esac
__prev="$__a"
done
resolve_lang
while [ $# -gt 0 ]; do
case "$1" in
--port) PORT="${2:-}"; shift 2 ;;
--service) SERVICE="${2:-}"; shift 2 ;;
--user) RUN_USER="${2:-}"; shift 2 ;;
--skip-frontend) SKIP_FRONTEND=1; shift ;;
--skip-deps) INSTALL_DEPS=0; shift ;;
--install-db) INSTALL_DB=1; shift ;;
--no-install-db) INSTALL_DB=0; shift ;;
--reset-db-password) RESET_DB_PASSWORD=1; shift ;;
-y|--yes) ASSUME_YES=1; shift ;;
--reconfigure) RECONFIGURE=1; shift ;;
--no-systemd) USE_SYSTEMD=0; shift ;;
--db-host) DB_HOST="${2:-}"; shift 2 ;;
--db-port) DB_PORT="${2:-}"; shift 2 ;;
--db-name) DB_NAME="${2:-}"; shift 2 ;;
--db-user) DB_USER="${2:-}"; shift 2 ;;
--db-password) DB_PASSWORD="${2:-}"; shift 2 ;;
--mysql-root-password) MYSQL_ROOT_PASSWORD="${2:-}"; shift 2 ;;
--lang) LANG_OPT="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) die "$(tf '未知参数:%s(用 --help 看用法)' "$1")" ;;
esac
done
# 主循环把 --lang 也认了一遍,这里再定一次语言:预扫只为了 --help / 未知参数
# 能说对语言,真正生效的取值以主循环解析出来的 LANG_OPT 为准。
resolve_lang
# 没指定语言且挂着终端时,问一句就往下走。必须在下面任何输出之前 ——
# 横幅、步骤名、结尾汇总全都要跟着选出来的语言走。
choose_lang
need_cmd() {
command -v "$1" >/dev/null 2>&1
}
# --- 交互提问 -----------------------------------------------------------------
# 默认**不提问**(见文件头的原则 4):一条命令跑到底,装完把账号口令打印出来。
# 只有显式 --reconfigure 才逐项提问,且必须有终端 —— 加 -y 或读不到 /dev/tty
# (cron / CI / 管道)时仍旧走默认值,脚本永远不会卡在等输入上。
# 注意方向:默认值是 0,这里要**显式打开**(早先默认是开、判定只负责关,改默认值
# 时很容易漏掉另一半 —— 那样 --reconfigure 会变成空操作)。
# 提示写 stderr、输入读 /dev/tty —— 这样即使 stdout 被 tee 进日志,交互也照常。
if [ "$RECONFIGURE" -eq 1 ] && [ "$ASSUME_YES" -ne 1 ] && have_tty; then
INTERACTIVE=1
else
INTERACTIVE=0
fi
# 普通提问:显示默认值,直接回车即接受
# 提示语在这一层统一翻译:调用点有十来处(全在 --reconfigure 分支里),
# 逐个包 t() 容易漏,而漏一条就是「英文界面里冒出一句中文提问」。
ask() {
local prompt="$1" default="$2" reply=""
[ "$INTERACTIVE" -eq 1 ] || { printf '%s' "$default"; return 0; }
printf ' %s [%s]: ' "$(t "$prompt")" "$default" >&2
IFS= read -r reply < /dev/tty || reply=""
printf '%s' "${reply:-$default}"
}
# 口令提问:不回显,也不把已有值打到屏幕上(只写「回车保留」)
ask_secret() {
local prompt="$1" default="$2" reply=""
[ "$INTERACTIVE" -eq 1 ] || { printf '%s' "$default"; return 0; }
if [ -n "$default" ]; then
printf ' %s [%s]: ' "$(t "$prompt")" "$(t '回车保留已设置的值')" >&2
else
printf ' %s: ' "$(t "$prompt")" >&2
fi
IFS= read -r -s reply < /dev/tty || reply=""
printf '\n' >&2
printf '%s' "${reply:-$default}"
}
# 是/否提问:返回 0 = 是(回车取默认值)
ask_yesno() {
local prompt="$1" default="${2:-y}" reply="" hint="Y/n"
[ "$default" = "y" ] || hint="y/N"
[ "$INTERACTIVE" -eq 1 ] || { [ "$default" = "y" ]; return $?; }
printf ' %s [%s]: ' "$(t "$prompt")" "$hint" >&2
IFS= read -r reply < /dev/tty || reply=""
case "${reply:-$default}" in
[Yy]*) return 0 ;;
*) return 1 ;;
esac
}
# 从 backend/.env 读一个键(不存在则输出空串)
#
# 解析规则刻意与 python-dotenv(后端 pydantic-settings 用的就是它)保持一致:
# 去掉值两侧空白、剥掉一层成对引号、未加引号时把「 #」之后当注释截掉。
# 两边规则不一致的后果很隐蔽:脚本用完整值去建库、应用用截断值来连 ——
# 命令全绿,装完服务才 Access denied。
env_get() {
[ -f "$ENV_FILE" ] || return 0
"$VPY" - "$ENV_FILE" "$1" <<'PY'
import pathlib, sys
path, key = sys.argv[1], sys.argv[2]
for line in pathlib.Path(path).read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or not line.startswith(key + "="):
continue
value = line.split("=", 1)[1].strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1] # 成对引号:原样取值,里面的 # 是内容
elif " #" in value:
value = value.split(" #", 1)[0].rstrip() # 未加引号:空格+# 起是注释
print(value)
break
PY
}
# 写入 / 覆盖一个键。
# 刻意用 Python 而不是 sed:数据库口令里出现 & | / \ 时,sed 的替换串会被
# 当成反向引用,写进去的就是一句错误的配置 —— 这种坑只在真实口令上才暴露。
env_set() {
"$VPY" - "$ENV_FILE" "$1" "$2" <<'PY'
import pathlib, re, sys
path, key, value = sys.argv[1], sys.argv[2], sys.argv[3]
p = pathlib.Path(path)
text = p.read_text(encoding="utf-8")
pattern = re.compile(rf"^{re.escape(key)}=.*$", re.M)
line = f"{key}={value}"
if pattern.search(text):
text = pattern.sub(lambda _m: line, text, count=1)
else:
text = text.rstrip("\n") + "\n" + line + "\n"
p.write_text(text, encoding="utf-8")
PY
}
echo "============================================================"
echo " $(t 'ProxCenter 部署')"
echo "$(tf ' 仓库目录 : %s' "$ROOT")"
echo "$(tf ' 服务名 : %s' "$SERVICE")"
echo "============================================================"
# ---------------------------------------------------------------------------
# 0. 前置检查 + 系统依赖
# ---------------------------------------------------------------------------
step "检查运行环境"
if [ "$USE_SYSTEMD" -eq 1 ]; then
[ "$(id -u)" -eq 0 ] || die "安装 systemd 服务需要 root:请用 sudo 执行,或加 --no-systemd 只准备环境。"
need_cmd systemctl || die "本机没有 systemd(systemctl 不存在),请改用 --no-systemd。"
fi
# --- 系统识别(只读,不装任何东西)-------------------------------------------
OS_NAME="$(t '未知系统')"; OS_ID=""; PKG=""; SUDO=""
if [ -r /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
OS_NAME="${PRETTY_NAME:-${NAME:-Linux}}"
OS_ID="${ID:-}"
fi
# 包管理器按命令探测:os-release 的家族名各发行版写法不一,命令最可靠
# (CentOS / Rocky / Alma / TencentOS / openEuler / Fedora 都是 dnf 或 yum)。
if need_cmd apt-get; then PKG="apt-get"
elif need_cmd dnf; then PKG="dnf"
elif need_cmd yum; then PKG="yum"
elif need_cmd zypper; then PKG="zypper"
elif need_cmd apk; then PKG="apk"
fi
[ "$(id -u)" -eq 0 ] || { need_cmd sudo && SUDO="sudo"; }
# 系统名带上发行版 ID。括号用半角:发行版 ID 是标识符的一部分而不是正文,
# 中文界面下写成「(TencentOS 4)」和后面的「|」挤在一起反而不匀。
os_label="$OS_NAME"
[ -n "$OS_ID" ] && os_label="$OS_NAME ($OS_ID)"
CAN_INSTALL=1
if [ "$INSTALL_DEPS" -eq 0 ]; then
CAN_INSTALL=0
info "已指定 --skip-deps:只检查依赖、不自动安装"
elif [ -z "$PKG" ]; then
CAN_INSTALL=0
warn "没有找到 apt-get / dnf / yum / zypper / apk,无法自动安装依赖"
elif [ "$(id -u)" -ne 0 ] && [ -z "$SUDO" ]; then
CAN_INSTALL=0
warn "当前不是 root 且没有 sudo,无法自动安装依赖(改用 sudo 重跑,或手工装)"
fi
info "$(tf '系统:%s|包管理器:%s' "$os_label" "${PKG:-$(t '未识别')}")"
# 需求名 → 该发行版要装的包。返回非 0 = 这个发行版没有对应包(调用方给手工指引)。
# 只列真正用得上的:Python 运行时 / venv / MySQL 客户端 / 构建前端用的 Node.js。
pkgs_for() {
case "$PKG:$1" in
apt-get:python) echo "python3" ;;
apt-get:venv) echo "python3-venv" ;;
apt-get:pip) echo "python3-pip" ;;
apt-get:dev) echo "python3-dev" ;;
apt-get:build) echo "build-essential" ;;
apt-get:mysql-cli) echo "default-mysql-client" ;;
apt-get:mysql-srv) echo "default-mysql-server" ;;
apt-get:node) echo "nodejs" ;;
apt-get:npm) echo "npm" ;;
apt-get:curl) echo "curl" ;;
apt-get:tar) echo "tar" ;;
apt-get:gzip) echo "gzip" ;;
# 图形验证码用系统 TTF 画字;没有字体时靠 Pillow 内置字体兜底(字会小一圈)
apt-get:fonts) echo "fonts-dejavu-core" ;;
# RHEL 支系(dnf / yum / zypper / apk)包名基本一致,合在一起写
dnf:python|yum:python|zypper:python|apk:python) echo "python3" ;;
# RHEL 的 venv 靠 python3-pip 带进来的 ensurepip;Debian 是独立的 python3-venv
dnf:venv|yum:venv|zypper:venv|apk:venv) echo "python3-pip" ;;
dnf:pip|yum:pip|zypper:pip|apk:pip) echo "python3-pip" ;;
dnf:dev|yum:dev|zypper:dev|apk:dev) echo "python3-devel" ;;
dnf:build|yum:build|zypper:build|apk:build) echo "gcc" ;;
dnf:mysql-cli|yum:mysql-cli|zypper:mysql-cli|apk:mysql-cli) echo "mariadb" ;;
dnf:mysql-srv|yum:mysql-srv|zypper:mysql-srv|apk:mysql-srv) echo "mariadb-server" ;;
dnf:node|yum:node|zypper:node|apk:node) echo "nodejs" ;;
dnf:npm|yum:npm|zypper:npm|apk:npm) echo "npm" ;;
dnf:curl|yum:curl|zypper:curl|apk:curl) echo "curl" ;;
dnf:tar|yum:tar|zypper:tar|apk:tar) echo "tar" ;;
dnf:gzip|yum:gzip|zypper:gzip|apk:gzip) echo "gzip" ;;
dnf:fonts|yum:fonts|zypper:fonts) echo "dejavu-sans-fonts" ;;
apk:fonts) echo "ttf-dejavu" ;;
*) return 1 ;;
esac
}
APT_UPDATED=0
# 装一批「需求名」。任何一个包名不认识就跳过它(并返回非 0),其余照装。
pkg_install() {
local want names all=()
for want in "$@"; do
if names="$(pkgs_for "$want")"; then
# shellcheck disable=SC2206
all+=($names)
else
warn "$(tf '本系统(%s)不认识「%s」对应的包,跳过' "${PKG:-$(t '未知')}" "$want")"
fi
done
[ ${#all[@]} -gt 0 ] || return 1
info "$(tf '安装:%s' "${all[*]}")"
case "$PKG" in
apt-get)
# 只 update 一次:多轮安装时反复 update 既慢又容易撞上镜像抖动
if [ "$APT_UPDATED" -eq 0 ]; then
$SUDO apt-get update -qq || warn "apt-get update 失败,继续尝试安装(离线环境属正常)"
APT_UPDATED=1
fi
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${all[@]}" ;;
dnf) $SUDO dnf install -y "${all[@]}" ;;
yum) $SUDO yum install -y "${all[@]}" ;;
zypper) $SUDO zypper --non-interactive install "${all[@]}" ;;
apk) $SUDO apk add --no-cache "${all[@]}" ;;
*) return 1 ;;
esac
}
# 缺什么装什么;装不上就返回 1,由调用方决定是报错还是退让。
ensure_pkg() {
local want="$1"
if [ "$CAN_INSTALL" -eq 1 ]; then
pkg_install "$want"
else
warn "$(tf '缺少「%s」,且当前条件下不能自动安装(--skip-deps / 无包管理器 / 无 root)' "$want")"
return 1
fi
}
# --- 基础工具:脚本自己就要用(curl 做健康检查,tar/gzip 解压前端产物)--------
for tool in curl tar gzip; do
need_cmd "$tool" || ensure_pkg "$tool" || true
done
# 一个 TTF 字体:图形验证码要靠系统字体画字。代码里对「一个字都没有」有兜底
# (改用 Pillow 内置的可缩放字体),但装了字体渲染效果更好,也不影响其它绘图功能。
if ! ls /usr/share/fonts/**/*.ttf >/dev/null 2>&1; then
ensure_pkg fonts || warn "没装字体,图形验证码将使用 Pillow 内置字体(能看清,但不如系统字体好看)"
fi
# --- Python 3.11+ -------------------------------------------------------------
# 与 start.sh / start-prod.sh 保持同一套候选顺序。
find_python() {
local candidate
for candidate in \
"${PYTHON_BIN:-}" \
"$HOME/.workbuddy/binaries/python/versions/3.13.12/bin/python3" \
"$VENV/bin/python" \
"python3.13" "python3.12" "python3.11" "python3" "python"
do
[ -n "$candidate" ] || continue
if command -v "$candidate" >/dev/null 2>&1; then
if "$candidate" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then
printf '%s' "$candidate"; return 0
fi
fi
done
return 1
}
PYTHON="$(find_python || true)"
if [ -z "$PYTHON" ] && [ "$CAN_INSTALL" -eq 1 ]; then
info "本机没有 Python 3.11+,尝试安装"
# 发行版自带的 python3 在 Debian 12 / Ubuntu 22.04+ / RHEL 9 系上就是 3.11 或更高;
# 老一点的 RHEL 8 系把 3.11 放在模块流里,所以下面再补一次模块流的尝试。
pkg_install python || true
if [ "$PKG" = "dnf" ] || [ "$PKG" = "yum" ]; then
info "尝试启用 python311 模块流(RHEL 8 系需要)"
$SUDO "$PKG" module reset python311 -y >/dev/null 2>&1 || true
$SUDO "$PKG" module enable python311 -y >/dev/null 2>&1 || true
$SUDO "$PKG" install -y python3.11 python3.11-pip >/dev/null 2>&1 || true
fi
PYTHON="$(find_python || true)"
fi
if [ -z "$PYTHON" ]; then
echo "[x] $(t '未找到 Python 3.11+,且自动安装没成功。请手工装一个再重跑:')" >&2
print_hint_list <<'CMDS'
Debian / Ubuntu|sudo apt install python3 python3-venv
RHEL 8 / CentOS 8|sudo dnf module enable python311 && sudo dnf install python3.11 python3.11-pip
RHEL 9 / TencentOS 4 / Rocky / Alma|sudo dnf install python3.11
其它发行版|用系统包管理器装 python3 ≥ 3.11
CMDS
echo " $(t '装好后也可以指定路径重跑:PYTHON_BIN=/usr/bin/python3.11 ./deploy.sh ...')" >&2
exit 1
fi
info "$(tf 'Python:%s(%s)' "$PYTHON" "$("$PYTHON" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")')")"
# 仓库完整性:脚本要用的三个入口文件缺一个都跑不下去。
# 提前检查而不是等 pip / npm 报「文件不存在」——那种报错看不出「需要重新拉取」。
for required in backend/requirements.txt backend/run.py package.json; do
[ -f "$ROOT/$required" ] || die "$(tf '缺少 %s,仓库不完整(建议重新 git clone 后再执行)。' "$required")"
done
# --- Node.js(只有要构建前端时才需要,且要 ≥ 22.22:react-router 8 的下限)--------------
# **按完整版本比较,不能只看主版本**:22.0~22.21 能混过「主版本 ≥ 22」这种粗判,
# 然后在构建阶段撞上 vite 8 的 Node 下限(22.12+),那一刻的报错远不如这里直白;
# react-router 8 又要 22.22,所以这条线就按 22.22 划。
NODE_MIN_MAJOR=22
NODE_MIN_MINOR=22
node_ok() {
need_cmd node || return 1
need_cmd npm || return 1
node -e "
const [a, b] = process.versions.node.split('.').map(Number);
process.exit(a > ${NODE_MIN_MAJOR} || (a === ${NODE_MIN_MAJOR} && b >= ${NODE_MIN_MINOR}) ? 0 : 1);
" >/dev/null 2>&1
}
# 发行版仓库基本到不了 22.22(Debian 12 / Ubuntu 24.04 是 18,Alpine 3.20 是 20,
# RHEL 8 只有 18/20 的模块流),所以最后一级退路是**官方预编译包**:解压到
# .node/ 里、只把 bin 挂进本次脚本的 PATH —— 不改 sources.list、不碰系统里已有的
# node,卸载就是删目录。
NODE_BOOTSTRAP_DIR="$ROOT/.node"
# 上次装好的那份先挂上:重复执行脚本时不该再下一次 50MB
node_bootstrap_use() {
local bin
for bin in "$NODE_BOOTSTRAP_DIR"/*/bin; do
[ -x "$bin/node" ] || continue
case ":$PATH:" in
*":$bin:"*) ;;
*) export PATH="$bin:$PATH" ;;
esac
done
return 0
}
node_arch() {
case "$(uname -m)" in
x86_64|amd64) echo "linux-x64" ;;
aarch64|arm64) echo "linux-arm64" ;;
armv7l) echo "linux-armv7l" ;;
ppc64le) echo "linux-ppc64le" ;;
s390x) echo "linux-s390x" ;;
*) return 1 ;;
esac
}
# 官方二进制是 glibc 版:Alpine(musl) 上解压出来也跑不起来,别白下 50MB。
# 先问 getconf(GNU_LIBC_VERSION 只有 glibc 认),再退回 ldd —— 后者在 RHEL 上
# 输出的是 "ldd (GNU libc) 2.38",只匹配 "GNU C Library" 会漏判(实测踩过)。
node_glibc() {
if need_cmd getconf && getconf GNU_LIBC_VERSION >/dev/null 2>&1; then return 0; fi
need_cmd ldd || return 1
ldd --version 2>&1 | head -n1 | grep -qiE "glibc|gnu libc|GNU C Library"
}
# 要装哪个版本:写死补丁号迟早会 404(22.x 的补丁版每两周往前挪一次),所以问
# index.json。优先最新的 24.x —— 与 .nvmrc / Dockerfile / CI 保持一致;24 的线
# 拿不到时再退 22.x 的最新补丁版。
node_pick_version() {
local base ver
if [ -n "${NODE_BOOTSTRAP_VERSION:-}" ]; then printf '%s' "$NODE_BOOTSTRAP_VERSION"; return 0; fi
need_cmd curl || return 1
for base in "https://npmmirror.com/mirrors/node" "https://nodejs.org/dist"; do
# index.json 是单行大 JSON,按版本号抓比解析整棵树省事,也不需要 jq
ver="$(curl -fsSL --connect-timeout 10 -m 60 "$base/index.json" 2>/dev/null \
| grep -oE '"version":"v24\.[0-9]+\.[0-9]+"' | grep -oE 'v24\.[0-9]+\.[0-9]+' \
| sort -V | tail -n1)"
if [ -z "$ver" ]; then
ver="$(curl -fsSL --connect-timeout 10 -m 60 "$base/index.json" 2>/dev/null \
| grep -oE '"version":"v22\.[0-9]+\.[0-9]+"' | grep -oE 'v22\.[0-9]+\.[0-9]+' \
| sort -V | tail -n1)"
fi
if [ -n "$ver" ]; then printf '%s' "${ver#v}"; return 0; fi
done
return 1
}
node_bootstrap() {
local arch ver dir url base tmp
arch="$(node_arch)" || { warn "$(tf '不支持的 CPU 架构(%s),没法自动下载 Node.js' "$(uname -m)")"; return 1; }
node_glibc || { warn "$(t '当前系统不是 glibc(Alpine/musl 这类),官方二进制跑不起来,需要从发行版仓库装 Node.js 22.22+')"; return 1; }
ver="$(node_pick_version)" || { warn "$(t '查不到可下载的 Node.js 版本(网络不通或镜像不可用)')"; return 1; }
dir="$NODE_BOOTSTRAP_DIR/node-v${ver}-${arch}"
if [ ! -x "$dir/bin/node" ]; then
mkdir -p "$NODE_BOOTSTRAP_DIR" || return 1
tmp="$NODE_BOOTSTRAP_DIR/.dl.$$"
local got=0
# 国内直连 nodejs.org 经常超时:先走 npmmirror(与 .npmrc 的源一致),再退官方
for base in "https://npmmirror.com/mirrors/node" "https://nodejs.org/dist"; do
url="$base/v$ver/node-v$ver-$arch.tar.gz"
info "$(tf '下载 Node.js %s:%s' "v$ver" "$url")"
if curl -fsSL --retry 2 --connect-timeout 15 -o "$tmp" "$url"; then got=1; break; fi
warn "$(tf '这个源不行,换下一个:%s' "$url")"
done
if [ "$got" -ne 1 ]; then rm -f "$tmp"; return 1; fi
info "$(tf '解压到 %s' "$NODE_BOOTSTRAP_DIR")"
if ! tar -xzf "$tmp" -C "$NODE_BOOTSTRAP_DIR"; then rm -f "$tmp"; return 1; fi
rm -f "$tmp"
fi
export PATH="$dir/bin:$PATH"