diff --git a/README.en.md b/README.en.md
index e541b5b..dabbeeb 100644
--- a/README.en.md
+++ b/README.en.md
@@ -10,7 +10,7 @@
English |
- 简体中文
+ 简体中文
@@ -88,17 +88,24 @@ Quick-start links:
| Guide | Description |
|-------|-------------|
+| [Repo-local: epctl installer](wiki/EPCTL.en.md) | Linux binary install, upgrade, status inspection, and Docker validation |
| [Docker Deployment](https://epusdt.com/guide/installation/docker) | Recommended one-command setup |
| [aaPanel Deployment](https://epusdt.com/guide/installation/aapanel) | Great for aaPanel users |
| [Manual Deployment](https://epusdt.com/guide/installation/manual.html) | Full manual control |
| [Developer API Docs](https://epusdt.com) | Integration reference |
+The repository also ships these top-level scripts:
+
+- [`./epctl`](./epctl) for Linux binary install, upgrade, config inspection, service status, logs, and initial password retrieval
+- [`./epctl-docker-test.sh`](./epctl-docker-test.sh) for real Ubuntu + systemd installation validation inside local Docker
+
---
## Project Structure
```text
Epusdt
+├── epctl Linux binary installation and operations script
├── src/ Core project source code
└── wiki/ Documentation assets and knowledge base
```
diff --git a/README.md b/README.md
index 8845987..9ddb8eb 100644
--- a/README.md
+++ b/README.md
@@ -94,17 +94,24 @@ Epusdt 已完成第三方安全审计。
| 教程 | 说明 |
|------|------|
+| [仓库内:epctl 安装脚本](wiki/EPCTL.md) | Linux 二进制安装、升级、状态查看与 Docker 验收脚本 |
| [Docker 部署](https://epusdt.com/guide/installation/docker) | 推荐方式,一键启动 |
| [宝塔面板部署](https://epusdt.com/guide/installation/aapanel) | 适合宝塔用户 |
| [手动部署](https://epusdt.com/guide/installation/manual.html) | 完全手动控制 |
| [开发者 API 文档](https://epusdt.com/zh/guide/integration/gmpay.html) | 接口集成指南 |
+仓库内还提供顶层脚本:
+
+- [`./epctl`](./epctl) 用于 Linux 二进制安装、升级、查看配置、状态和初始化密码
+- [`./epctl-docker-test.sh`](./epctl-docker-test.sh) 用于在本机 Docker 里跑 Ubuntu + systemd 的真实安装验收
+
---
## 项目结构
```text
Epusdt
+├── epctl Linux 二进制安装与运维脚本
├── src/ 项目核心代码
└── wiki/ 文档与知识库
```
diff --git a/epctl b/epctl
new file mode 100755
index 0000000..2d0c0bb
--- /dev/null
+++ b/epctl
@@ -0,0 +1,1467 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+readonly EPCTL_NAME="epctl"
+readonly REPO_OWNER="GMWalletApp"
+readonly REPO_NAME="epusdt"
+readonly SERVICE_NAME="epusdt"
+readonly SYSTEM_USER="epusdt"
+readonly INSTALL_DIR="/opt/epusdt"
+readonly BIN_PATH="${INSTALL_DIR}/epusdt"
+readonly CONFIG_PATH="${INSTALL_DIR}/.env"
+readonly CONFIG_EXAMPLE_PATH="${INSTALL_DIR}/.env.example"
+readonly WWW_PATH="${INSTALL_DIR}/www"
+readonly CACHE_ROOT="/tmp/epusdt"
+readonly SYSTEMD_UNIT_PATH="/etc/systemd/system/${SERVICE_NAME}.service"
+readonly SELF_INSTALL_PATH="/usr/local/bin/${EPCTL_NAME}"
+readonly DEFAULT_APP_URI="http://127.0.0.1:8000"
+readonly DEFAULT_LISTEN="127.0.0.1:8000"
+readonly SCRIPT_SOURCE_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
+
+COLOR_RESET=""
+COLOR_BOLD=""
+COLOR_DIM=""
+COLOR_CYAN=""
+COLOR_GREEN=""
+COLOR_YELLOW=""
+COLOR_RED=""
+COLOR_BLUE=""
+
+ACTIVE_LANG=""
+POSITIONAL_ARGS=()
+
+detect_default_language() {
+ local raw normalized
+
+ raw="${EPCTL_LANG:-}"
+ if [[ -z "${raw}" ]]; then
+ printf 'zh\n'
+ return
+ fi
+
+ normalized="${raw,,}"
+ case "${normalized}" in
+ zh|zh-*|zh_*|cn|chs|cht|中文)
+ printf 'zh\n'
+ ;;
+ en|en-*|en_*|english)
+ printf 'en\n'
+ ;;
+ *)
+ printf '%s\n' "${raw}"
+ ;;
+ esac
+}
+
+set_language() {
+ local requested normalized fallback_lang
+
+ requested="${1:-$(detect_default_language)}"
+ normalized="${requested,,}"
+ fallback_lang="${ACTIVE_LANG:-$(detect_default_language)}"
+ case "${normalized}" in
+ zh|zh-*|zh_*|cn|chs|cht|中文)
+ ACTIVE_LANG="zh"
+ ;;
+ en|en-*|en_*|english)
+ ACTIVE_LANG="en"
+ ;;
+ *)
+ ACTIVE_LANG="${fallback_lang}"
+ die "$(trf unsupported_language "${requested}")"
+ ;;
+ esac
+
+ export EPCTL_LANG="${ACTIVE_LANG}"
+}
+
+trf() {
+ local key="$1"
+ shift || true
+ local template
+
+ case "${ACTIVE_LANG}:${key}" in
+ en:label_warn) template='warn' ;;
+ zh:label_warn) template='警告' ;;
+ en:label_ok) template='ok' ;;
+ zh:label_ok) template='完成' ;;
+ en:label_error) template='error' ;;
+ zh:label_error) template='错误' ;;
+ en:only_linux) template='this script only supports Linux' ;;
+ zh:only_linux) template='本脚本仅支持 Linux' ;;
+ en:missing_dependency) template='missing dependency: %s' ;;
+ zh:missing_dependency) template='缺少依赖:%s' ;;
+ en:root_required) template='this command must be run as root' ;;
+ zh:root_required) template='此命令必须以 root 身份执行' ;;
+ en:release_tag_required) template='release tag is required' ;;
+ zh:release_tag_required) template='必须提供 release tag' ;;
+ en:release_tag_invalid) template='--tag must match a release tag such as v1.0.6' ;;
+ zh:release_tag_invalid) template='--tag 必须是有效的 release tag,例如 v1.0.6' ;;
+ en:unsupported_arch) template='unsupported architecture: %s' ;;
+ zh:unsupported_arch) template='不支持的架构:%s' ;;
+ en:checksum_missing) template='checksum entry for %s not found in %s' ;;
+ zh:checksum_missing) template='在 %s 中找不到 %s 的校验项' ;;
+ en:latest_release_resolve_failed) template='failed to resolve the latest release tag from GitHub' ;;
+ zh:latest_release_resolve_failed) template='无法从 GitHub 解析最新 release tag' ;;
+ en:using_latest_without_confirmation) template='using latest release tag %s without confirmation because EPCTL_ASSUME_YES=1' ;;
+ zh:using_latest_without_confirmation) template='检测到 EPCTL_ASSUME_YES=1,直接使用最新 release tag %s,不再二次确认' ;;
+ en:latest_tag_no_tty) template='no --tag provided and no interactive terminal available; pass --tag explicitly or set EPCTL_ASSUME_YES=1' ;;
+ zh:latest_tag_no_tty) template='未提供 --tag,且当前不是交互终端;请显式传入 --tag,或设置 EPCTL_ASSUME_YES=1' ;;
+ en:latest_prompt) template='latest release tag is %s. Continue? [y/N] ' ;;
+ zh:latest_prompt) template='检测到最新 release tag 为 %s,是否继续?[y/N] ' ;;
+ en:cancelled) template='cancelled' ;;
+ zh:cancelled) template='已取消' ;;
+ en:cached_release_files) template='using cached release files in %s' ;;
+ zh:cached_release_files) template='使用缓存中的 release 文件:%s' ;;
+ en:downloading_archive) template='downloading %s' ;;
+ zh:downloading_archive) template='正在下载 %s' ;;
+ en:checksum_skipped) template='sha256sum not found; skipped checksum verification' ;;
+ zh:checksum_skipped) template='未检测到 sha256sum,已跳过校验' ;;
+ en:archive_missing_binary) template='release archive is missing the epusdt binary' ;;
+ zh:archive_missing_binary) template='release 压缩包中缺少 epusdt 二进制文件' ;;
+ en:archive_missing_env_example) template='release archive is missing .env.example' ;;
+ zh:archive_missing_env_example) template='release 压缩包中缺少 .env.example' ;;
+ en:keeping_existing_config) template='keeping existing config at %s' ;;
+ zh:keeping_existing_config) template='保留现有配置文件:%s' ;;
+ en:service_failed_active) template='service %s failed to become active' ;;
+ zh:service_failed_active) template='服务 %s 未能成功进入 active 状态' ;;
+ en:service_installed) template='installed %s to %s from %s' ;;
+ zh:service_installed) template='已将 %s 安装到 %s,来源版本:%s' ;;
+ en:self_install_current) template='%s already points to the current script' ;;
+ zh:self_install_current) template='%s 已经指向当前脚本,无需重复安装' ;;
+ en:self_install_done) template='installed %s into /usr/local/bin' ;;
+ zh:self_install_done) template='已将 %s 安装到 /usr/local/bin' ;;
+ en:config_not_found) template='config file not found: %s' ;;
+ zh:config_not_found) template='找不到配置文件:%s' ;;
+ en:http_listen_not_found) template='http_listen not found in %s' ;;
+ zh:http_listen_not_found) template='在 %s 中找不到 http_listen' ;;
+ en:http_listen_parse_failed) template='failed to parse the port from http_listen=%s' ;;
+ zh:http_listen_parse_failed) template='无法从 http_listen=%s 中解析端口' ;;
+ en:curl_partial_body) template='curl failed and returned a partial response body:' ;;
+ zh:curl_partial_body) template='curl 请求失败,并返回了部分响应体:' ;;
+ en:init_password_http_failed) template='init-password request failed with HTTP %s: %s' ;;
+ zh:init_password_http_failed) template='init-password 请求失败,HTTP %s:%s' ;;
+ en:response_body_label) template='response body:' ;;
+ zh:response_body_label) template='响应体:' ;;
+ en:response_body_empty) template='response body is empty' ;;
+ zh:response_body_empty) template='响应体为空' ;;
+ en:lines_positive_integer) template='--lines must be a positive integer' ;;
+ zh:lines_positive_integer) template='--lines 必须是正整数' ;;
+ en:state_not_installed) template='not installed' ;;
+ zh:state_not_installed) template='未安装' ;;
+ en:state_no_systemctl) template='unit exists, but systemctl is unavailable' ;;
+ zh:state_no_systemctl) template='unit 文件已存在,但 systemctl 不可用' ;;
+ en:state_no_systemd) template='unit exists, but systemd is unavailable' ;;
+ zh:state_no_systemd) template='unit 文件已存在,但 systemd 不可用' ;;
+ en:state_active) template='active' ;;
+ zh:state_active) template='运行中' ;;
+ en:state_inactive) template='installed but inactive' ;;
+ zh:state_inactive) template='已安装,但未运行' ;;
+ en:menu_title) template='EPCTL Console' ;;
+ zh:menu_title) template='EPCTL 控制台' ;;
+ en:menu_subtitle) template='Linux binary manager for epusdt' ;;
+ zh:menu_subtitle) template='面向 epusdt 的 Linux 二进制管理工具' ;;
+ en:label_service) template='service' ;;
+ zh:label_service) template='服务状态' ;;
+ en:label_install_dir) template='install dir' ;;
+ zh:label_install_dir) template='安装目录' ;;
+ en:label_config) template='config' ;;
+ zh:label_config) template='配置文件' ;;
+ en:menu_section_setup) template='Setup and release management' ;;
+ zh:menu_section_setup) template='部署与发布' ;;
+ en:menu_section_runtime) template='Runtime inspection' ;;
+ zh:menu_section_runtime) template='运行查看' ;;
+ en:menu_section_tools) template='Language and help' ;;
+ zh:menu_section_tools) template='语言与帮助' ;;
+ en:menu_badge_direct) template='direct' ;;
+ zh:menu_badge_direct) template='直达' ;;
+ en:menu_badge_confirm) template='confirm' ;;
+ zh:menu_badge_confirm) template='确认' ;;
+ en:menu_exit) template='Quit' ;;
+ zh:menu_exit) template='退出' ;;
+ en:menu_hint_direct) template='Direct items run immediately after selection.' ;;
+ zh:menu_hint_direct) template='带 [直达] 的项目会在选择后直接执行。' ;;
+ en:menu_hint_confirm) template='Confirm items show an extra confirmation step first.' ;;
+ zh:menu_hint_confirm) template='带 [确认] 的项目会先显示确认步骤。' ;;
+ en:command_completed) template='command completed' ;;
+ zh:command_completed) template='命令执行完成' ;;
+ en:command_failed_exit) template='command failed with exit code %s' ;;
+ zh:command_failed_exit) template='命令执行失败,退出码:%s' ;;
+ en:invalid_selection) template='invalid selection: %s' ;;
+ zh:invalid_selection) template='无效选择:%s' ;;
+ en:arg_requires_value) template='%s requires a value' ;;
+ zh:arg_requires_value) template='%s 需要一个参数值' ;;
+ en:unknown_arg) template='unknown argument for %s: %s' ;;
+ zh:unknown_arg) template='%s 命令不支持该参数:%s' ;;
+ en:unknown_command) template='unknown command: %s' ;;
+ zh:unknown_command) template='未知命令:%s' ;;
+ en:input_release_tag) template='Release tag (blank = latest)' ;;
+ zh:input_release_tag) template='Release tag(留空则使用 latest)' ;;
+ en:input_upgrade_tag) template='Upgrade tag (blank = latest)' ;;
+ zh:input_upgrade_tag) template='升级目标 tag(留空则使用 latest)' ;;
+ en:input_app_uri) template='app_uri' ;;
+ zh:input_app_uri) template='app_uri' ;;
+ en:input_http_listen) template='http_listen' ;;
+ zh:input_http_listen) template='http_listen' ;;
+ en:input_log_lines) template='Log lines' ;;
+ zh:input_log_lines) template='日志行数' ;;
+ en:press_enter_continue) template='Press Enter to return to the menu...' ;;
+ zh:press_enter_continue) template='按回车返回菜单...' ;;
+ en:menu_select_action) template='Select an action [0-10]: ' ;;
+ zh:menu_select_action) template='请选择操作 [0-10]:' ;;
+ en:menu_language_prompt) template='Choose a language / 请选择语言: 1) 中文 2) English' ;;
+ zh:menu_language_prompt) template='请选择语言 / Choose a language:1)中文 2)English' ;;
+ en:menu_language_input) template='Language selection(语言选择)' ;;
+ zh:menu_language_input) template='语言选择(Language selection)' ;;
+ en:menu_confirm_prompt) template='Continue? / 是否继续? [y/N] ' ;;
+ zh:menu_confirm_prompt) template='是否继续?/ Continue? [y/N] ' ;;
+ en:menu_cancelled_back) template='Cancelled and returned to the menu / 已取消,返回菜单' ;;
+ zh:menu_cancelled_back) template='已取消,返回菜单 / Cancelled and returned to the menu' ;;
+ en:language_switched) template='Language switched to %s / 语言已切换为 %s' ;;
+ zh:language_switched) template='语言已切换为 %s / Language switched to %s' ;;
+ en:language_name_zh) template='中文' ;;
+ zh:language_name_zh) template='中文' ;;
+ en:language_name_en) template='English' ;;
+ zh:language_name_en) template='English' ;;
+ en:unsupported_language) template='unsupported language: %s' ;;
+ zh:unsupported_language) template='不支持的语言:%s' ;;
+ en:current_language) template='current language' ;;
+ zh:current_language) template='当前语言' ;;
+ en:base_image_label) template='base image' ;;
+ zh:base_image_label) template='基础镜像' ;;
+ en:action_download_title) template='Download release package' ;;
+ zh:action_download_title) template='下载 release 包' ;;
+ en:action_download_desc) template='This will download the selected GitHub release archive into %s, and verify SHA256 when sha256sum is available. It will not install or start the service.' ;;
+ zh:action_download_desc) template='此操作会把选定的 GitHub release 压缩包下载到 %s,并在本机有 sha256sum 时校验 SHA256。它不会安装,也不会启动服务。' ;;
+ en:action_install_title) template='Install or refresh the service' ;;
+ zh:action_install_title) template='安装或刷新服务' ;;
+ en:action_install_desc) template='This will install epusdt into %s, create or reuse %s, write %s, and enable %s through systemd. Existing .env will be kept.' ;;
+ zh:action_install_desc) template='此操作会把 epusdt 安装到 %s,创建或复用 %s,写入 %s,并通过 systemd 启用 %s。若已有 .env,则会保留。' ;;
+ en:action_upgrade_title) template='Upgrade the service' ;;
+ zh:action_upgrade_title) template='升级服务' ;;
+ en:action_upgrade_desc) template='This will download and install a newer release into %s, then restart %s. The existing .env configuration will be preserved.' ;;
+ zh:action_upgrade_desc) template='此操作会下载并安装较新的 release 到 %s,然后重启 %s。现有 .env 配置会被保留。' ;;
+ en:action_self_install_title) template='Install epctl into PATH' ;;
+ zh:action_self_install_title) template='安装 epctl 到 PATH' ;;
+ en:action_self_install_desc) template='This will install %s into /usr/local/bin so it can be called from anywhere. Use %s to force Chinese or %s to force English.' ;;
+ zh:action_self_install_desc) template='此操作会把 %s 安装到 /usr/local/bin,便于在任意目录直接调用。之后可用 %s 强制中文,或用 %s 强制英文。' ;;
+ en:action_show_config_title) template='Show active config' ;;
+ zh:action_show_config_title) template='查看当前配置' ;;
+ en:action_show_config_desc) template='This will print the full active configuration file %s to the terminal. Sensitive values will be visible.' ;;
+ zh:action_show_config_desc) template='此操作会把当前生效的配置文件 %s 完整输出到终端,其中敏感配置也会直接显示。' ;;
+ en:action_init_password_title) template='Request initial admin password' ;;
+ zh:action_init_password_title) template='获取初始化密码' ;;
+ en:action_init_password_desc) template='This will call the local HTTP route /admin/api/v1/auth/init-password based on http_listen in %s. It does not read the database directly.' ;;
+ zh:action_init_password_desc) template='此操作会根据 %s 中的 http_listen,请求本地 HTTP 路由 /admin/api/v1/auth/init-password。它不会直接读取数据库。' ;;
+ en:action_status_title) template='Show service status' ;;
+ zh:action_status_title) template='查看服务状态' ;;
+ en:action_status_desc) template='This will run systemctl status for %s and print the current runtime state, recent logs, and unit summary.' ;;
+ zh:action_status_desc) template='此操作会对 %s 执行 systemctl status,并输出当前运行状态、最近日志和 unit 摘要。' ;;
+ en:action_logs_title) template='Show recent logs' ;;
+ zh:action_logs_title) template='查看最近日志' ;;
+ en:action_logs_desc) template='This will read recent journal logs for %s. You can choose how many lines to print next.' ;;
+ zh:action_logs_desc) template='此操作会读取 %s 的最近 journal 日志。下一步你可以选择要输出多少行。' ;;
+ en:action_language_title) template='Switch language(切换语言)' ;;
+ zh:action_language_title) template='Switch language(切换语言)' ;;
+ en:action_language_desc) template='This only changes the current interactive interface language. It does not change the service config or installation files. / 此操作只会切换当前交互界面的语言,不会修改服务配置,也不会改动安装文件。' ;;
+ zh:action_language_desc) template='此操作只会切换当前交互界面的语言,不会修改服务配置,也不会改动安装文件。 / This only changes the current interactive interface language. It does not change the service config or installation files.' ;;
+ en:action_help_title) template='Show help' ;;
+ zh:action_help_title) template='查看帮助' ;;
+ en:action_help_desc) template='This will print the full command help, including all non-interactive commands and global options.' ;;
+ zh:action_help_desc) template='此操作会输出完整命令帮助,包括所有非交互命令和全局参数。' ;;
+ *)
+ template="missing translation: ${key}"
+ ;;
+ esac
+
+ printf -- "${template}" "$@"
+}
+
+init_colors() {
+ if [[ -n "${NO_COLOR:-}" || "${EPCTL_NO_COLOR:-}" == "1" ]]; then
+ return
+ fi
+
+ if [[ -t 1 || -t 2 ]]; then
+ COLOR_RESET=$'\033[0m'
+ COLOR_BOLD=$'\033[1m'
+ COLOR_DIM=$'\033[2m'
+ COLOR_CYAN=$'\033[36m'
+ COLOR_GREEN=$'\033[32m'
+ COLOR_YELLOW=$'\033[33m'
+ COLOR_RED=$'\033[31m'
+ COLOR_BLUE=$'\033[34m'
+ fi
+}
+
+log() {
+ printf '%b[%s]%b %s\n' "${COLOR_CYAN}${COLOR_BOLD}" "${EPCTL_NAME}" "${COLOR_RESET}" "$*" >&2
+}
+
+warn() {
+ printf '%b[%s] %s:%b %s\n' "${COLOR_YELLOW}${COLOR_BOLD}" "${EPCTL_NAME}" "$(trf label_warn)" "${COLOR_RESET}" "$*" >&2
+}
+
+success() {
+ printf '%b[%s] %s:%b %s\n' "${COLOR_GREEN}${COLOR_BOLD}" "${EPCTL_NAME}" "$(trf label_ok)" "${COLOR_RESET}" "$*" >&2
+}
+
+die() {
+ printf '%b[%s] %s:%b %s\n' "${COLOR_RED}${COLOR_BOLD}" "${EPCTL_NAME}" "$(trf label_error)" "${COLOR_RESET}" "$*" >&2
+ exit 1
+}
+
+usage() {
+ if [[ "${ACTIVE_LANG}" == "zh" ]]; then
+ cat <<'EOF'
+用法:
+ epctl [--lang zh|en] [--no-color]
+ epctl zh
+ epctl en
+ epctl [--lang zh|en] menu
+ epctl [--lang zh|en] download [--tag ]
+ epctl [--lang zh|en] install [--tag ] [--app-uri URL] [--listen ADDR:PORT]
+ epctl [--lang zh|en] upgrade [--tag ]
+ epctl [--lang zh|en] self-install
+ epctl [--lang zh|en] show-config
+ epctl [--lang zh|en] init-password
+ epctl [--lang zh|en] status
+ epctl [--lang zh|en] logs [--lines N]
+
+全局选项:
+ --lang zh|en 切换界面语言
+ --no-color 禁用彩色输出
+ 快捷方式 `epctl zh` 直接进入中文;`epctl en` 直接进入英文
+
+命令说明:
+ menu 进入交互式菜单;直接执行 `epctl` 也会进入
+ download 下载 GitHub Release 包到 /tmp/epusdt//
+ install 安装或刷新 /opt/epusdt,并注册 epusdt.service
+ upgrade 升级到新的 release,保留现有 .env
+ self-install 安装 epctl 到 /usr/local/bin
+ show-config 输出当前生效的 /opt/epusdt/.env
+ init-password 通过本地 HTTP 路由请求初始化管理员密码
+ status 显示 epusdt 的 systemd 状态
+ logs 显示最近的服务日志
+
+说明:
+ - 仅支持 Linux
+ - 仅支持二进制安装
+ - install / upgrade / self-install 会写入 /opt、/etc/systemd、/usr/local/bin
+ - 需要提权的命令会自动通过 sudo 重新执行
+ - tag 需要是真实的 GitHub release tag,例如 v1.0.6
+ - 未提供 --tag 时,会自动解析 latest,并要求二次确认
+EOF
+ else
+ cat <<'EOF'
+Usage:
+ epctl [--lang zh|en] [--no-color]
+ epctl zh
+ epctl en
+ epctl [--lang zh|en] menu
+ epctl [--lang zh|en] download [--tag ]
+ epctl [--lang zh|en] install [--tag ] [--app-uri URL] [--listen ADDR:PORT]
+ epctl [--lang zh|en] upgrade [--tag ]
+ epctl [--lang zh|en] self-install
+ epctl [--lang zh|en] show-config
+ epctl [--lang zh|en] init-password
+ epctl [--lang zh|en] status
+ epctl [--lang zh|en] logs [--lines N]
+
+Global options:
+ --lang zh|en Select the interface language
+ --no-color Disable colored output
+ Shortcut `epctl zh` enters Chinese directly; `epctl en` enters English directly
+
+Command summary:
+ menu Open the interactive menu; `epctl` with no arguments does this too
+ download Download a GitHub release archive into /tmp/epusdt//
+ install Install or refresh /opt/epusdt and register epusdt.service
+ upgrade Upgrade to a newer release while keeping the existing .env
+ self-install Install epctl into /usr/local/bin
+ show-config Print the active /opt/epusdt/.env file
+ init-password Request the initial admin password from the local HTTP route
+ status Show the systemd status of epusdt
+ logs Show recent service logs
+
+Notes:
+ - Linux only
+ - Binary installation only
+ - install / upgrade / self-install write into /opt, /etc/systemd, and /usr/local/bin
+ - privileged commands automatically re-exec through sudo
+ - tags must match real GitHub release tags such as v1.0.6
+ - if --tag is omitted, epctl resolves the latest release and asks for confirmation
+EOF
+ fi
+}
+
+parse_global_options() {
+ POSITIONAL_ARGS=()
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --lang)
+ [[ $# -ge 2 ]] || die "$(trf arg_requires_value "--lang")"
+ set_language "$2"
+ shift 2
+ ;;
+ --lang=*)
+ set_language "${1#*=}"
+ shift
+ ;;
+ --no-color)
+ export EPCTL_NO_COLOR=1
+ shift
+ ;;
+ *)
+ POSITIONAL_ARGS+=("$1")
+ shift
+ ;;
+ esac
+ done
+}
+
+require_linux() {
+ [[ "$(uname -s)" == "Linux" ]] || die "$(trf only_linux)"
+}
+
+have_command() {
+ command -v "$1" >/dev/null 2>&1
+}
+
+require_command() {
+ have_command "$1" || die "$(trf missing_dependency "$1")"
+}
+
+is_interactive_terminal() {
+ [[ -t 0 && -t 1 ]]
+}
+
+require_root() {
+ [[ "${EUID}" -eq 0 ]] || die "$(trf root_required)"
+}
+
+ensure_root_via_sudo() {
+ if [[ "${EUID}" -eq 0 ]]; then
+ return
+ fi
+
+ require_command sudo
+ exec sudo -- "${SCRIPT_SOURCE_PATH}" --lang "${ACTIVE_LANG}" "$@"
+}
+
+require_release_tag() {
+ local tag="$1"
+ [[ -n "${tag}" ]] || die "$(trf release_tag_required)"
+ [[ "${tag}" == v* ]] || die "$(trf release_tag_invalid)"
+}
+
+map_arch() {
+ case "$(uname -m)" in
+ x86_64|amd64)
+ printf 'amd64\n'
+ ;;
+ aarch64|arm64)
+ printf 'arm64\n'
+ ;;
+ *)
+ die "$(trf unsupported_arch "$(uname -m)")"
+ ;;
+ esac
+}
+
+asset_version_from_tag() {
+ local tag="$1"
+ printf '%s\n' "${tag#v}"
+}
+
+cache_dir_for_tag() {
+ local tag="$1"
+ printf '%s/%s\n' "${CACHE_ROOT}" "${tag}"
+}
+
+archive_name_for_tag() {
+ local tag="$1"
+ local arch="$2"
+ printf 'epusdt-%s-linux-%s.tar.gz\n' "$(asset_version_from_tag "${tag}")" "${arch}"
+}
+
+release_base_url_for_tag() {
+ local tag="$1"
+ printf 'https://github.com/%s/%s/releases/download/%s\n' "${REPO_OWNER}" "${REPO_NAME}" "${tag}"
+}
+
+download_file() {
+ local url="$1"
+ local destination="$2"
+ local partial="${destination}.part"
+ rm -f "${partial}"
+ curl --fail --location --silent --show-error --output "${partial}" "${url}"
+ mv "${partial}" "${destination}"
+}
+
+verify_checksum() {
+ local sums_path="$1"
+ local archive_path="$2"
+ local archive_name
+ local checksum_line
+
+ archive_name="$(basename "${archive_path}")"
+ checksum_line="$(grep -F " ${archive_name}" "${sums_path}" || true)"
+ [[ -n "${checksum_line}" ]] || die "$(trf checksum_missing "${archive_name}" "$(basename "${sums_path}")")"
+
+ (
+ cd "$(dirname "${archive_path}")"
+ printf '%s\n' "${checksum_line}" | sha256sum -c - >&2
+ )
+}
+
+ensure_download_dependencies() {
+ require_command curl
+ require_command grep
+ require_command sed
+}
+
+latest_release_tag() {
+ local api_url="https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest"
+ local response tag
+
+ ensure_download_dependencies
+ response="$(curl --fail --location --silent --show-error "${api_url}")"
+ tag="$(printf '%s\n' "${response}" | sed -n 's/^[[:space:]]*"tag_name":[[:space:]]*"\([^"]\+\)".*$/\1/p' | head -n 1)"
+ [[ -n "${tag}" ]] || die "$(trf latest_release_resolve_failed)"
+ require_release_tag "${tag}"
+ printf '%s\n' "${tag}"
+}
+
+is_affirmative_reply() {
+ local reply="${1:-}"
+ reply="${reply,,}"
+
+ case "${reply}" in
+ y|yes|ok|true|1|s|shi|是|确认|確認)
+ return 0
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+}
+
+confirm_latest_release_tag() {
+ local tag="$1"
+ local reply
+
+ if [[ "${EPCTL_ASSUME_YES:-}" == "1" ]]; then
+ log "$(trf using_latest_without_confirmation "${tag}")"
+ printf '%s\n' "${tag}"
+ return
+ fi
+
+ if [[ ! -t 0 ]]; then
+ die "$(trf latest_tag_no_tty)"
+ fi
+
+ printf '%b[%s]%b %s' "${COLOR_CYAN}${COLOR_BOLD}" "${EPCTL_NAME}" "${COLOR_RESET}" "$(trf latest_prompt "${tag}")" >&2
+ read -r reply
+ if is_affirmative_reply "${reply}"; then
+ printf '%s\n' "${tag}"
+ return
+ fi
+
+ die "$(trf cancelled)"
+}
+
+resolve_effective_tag() {
+ local requested_tag="$1"
+ local latest_tag
+
+ if [[ -n "${requested_tag}" ]]; then
+ require_release_tag "${requested_tag}"
+ printf '%s\n' "${requested_tag}"
+ return
+ fi
+
+ latest_tag="$(latest_release_tag)"
+ confirm_latest_release_tag "${latest_tag}"
+}
+
+download_release() {
+ local tag="$1"
+ local arch archive_name release_base_url cache_dir archive_path sums_path
+
+ require_linux
+ ensure_download_dependencies
+
+ arch="$(map_arch)"
+ archive_name="$(archive_name_for_tag "${tag}" "${arch}")"
+ release_base_url="$(release_base_url_for_tag "${tag}")"
+ cache_dir="$(cache_dir_for_tag "${tag}")"
+ archive_path="${cache_dir}/${archive_name}"
+ sums_path="${cache_dir}/SHA256SUMS"
+
+ mkdir -p "${cache_dir}"
+
+ if [[ -f "${archive_path}" && -f "${sums_path}" ]]; then
+ log "$(trf cached_release_files "${cache_dir}")"
+ else
+ log "$(trf downloading_archive "${archive_name}")"
+ download_file "${release_base_url}/${archive_name}" "${archive_path}"
+ download_file "${release_base_url}/SHA256SUMS" "${sums_path}"
+ fi
+
+ if have_command sha256sum; then
+ verify_checksum "${sums_path}" "${archive_path}"
+ else
+ warn "$(trf checksum_skipped)"
+ fi
+
+ printf '%s\n' "${archive_path}"
+}
+
+escape_sed_replacement() {
+ printf '%s' "$1" | sed -e 's/[&|\\]/\\&/g'
+}
+
+set_env_key() {
+ local file="$1"
+ local key="$2"
+ local value="$3"
+ local escaped
+
+ escaped="$(escape_sed_replacement "${value}")"
+ if grep -q "^${key}=" "${file}"; then
+ sed -i "s|^${key}=.*$|${key}=${escaped}|" "${file}"
+ else
+ printf '%s=%s\n' "${key}" "${value}" >> "${file}"
+ fi
+}
+
+ensure_system_user() {
+ if ! getent group "${SYSTEM_USER}" >/dev/null 2>&1; then
+ groupadd --system "${SYSTEM_USER}"
+ fi
+ if ! id -u "${SYSTEM_USER}" >/dev/null 2>&1; then
+ useradd --system --gid "${SYSTEM_USER}" --home-dir "${INSTALL_DIR}" --shell /usr/sbin/nologin "${SYSTEM_USER}"
+ fi
+}
+
+extract_release_archive() {
+ local tag="$1"
+ local archive_path="$2"
+ local extract_dir
+
+ extract_dir="$(cache_dir_for_tag "${tag}")/extract"
+ rm -rf "${extract_dir}"
+ mkdir -p "${extract_dir}"
+ tar -xzf "${archive_path}" -C "${extract_dir}"
+ [[ -f "${extract_dir}/epusdt" ]] || die "$(trf archive_missing_binary)"
+ [[ -f "${extract_dir}/.env.example" ]] || die "$(trf archive_missing_env_example)"
+ printf '%s\n' "${extract_dir}"
+}
+
+create_default_config_if_missing() {
+ local app_uri="$1"
+ local listen="$2"
+
+ if [[ -f "${CONFIG_PATH}" ]]; then
+ log "$(trf keeping_existing_config "${CONFIG_PATH}")"
+ return
+ fi
+
+ cp "${CONFIG_EXAMPLE_PATH}" "${CONFIG_PATH}"
+ set_env_key "${CONFIG_PATH}" "install" "false"
+ set_env_key "${CONFIG_PATH}" "app_uri" "${app_uri}"
+ set_env_key "${CONFIG_PATH}" "http_listen" "${listen}"
+ chmod 0644 "${CONFIG_PATH}"
+}
+
+write_systemd_unit() {
+ cat > "${SYSTEMD_UNIT_PATH}" <&2
+ printf '\n' >&2
+ fi
+ rm -f "${body_file}"
+ return "${curl_rc}"
+ fi
+
+ if [[ "${http_code}" =~ ^2[0-9][0-9]$ ]]; then
+ cat "${body_file}"
+ rm -f "${body_file}"
+ return 0
+ fi
+
+ warn "$(trf init_password_http_failed "${http_code}" "${url}")"
+ if [[ -s "${body_file}" ]]; then
+ printf '%b%s%b\n' "${COLOR_YELLOW}${COLOR_BOLD}" "$(trf response_body_label)" "${COLOR_RESET}" >&2
+ cat "${body_file}" >&2
+ printf '\n' >&2
+ else
+ warn "$(trf response_body_empty)"
+ fi
+ rm -f "${body_file}"
+ return 1
+}
+
+command_status() {
+ local original_args=("$@")
+
+ require_linux
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ die "$(trf unknown_arg "status" "$1")"
+ ;;
+ esac
+ done
+
+ ensure_root_via_sudo status "${original_args[@]}"
+ require_root
+ require_command systemctl
+ systemctl status "${SERVICE_NAME}" --no-pager
+}
+
+command_logs() {
+ local lines="100"
+ local original_args=("$@")
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --lines)
+ [[ $# -ge 2 ]] || die "$(trf arg_requires_value "--lines")"
+ lines="$2"
+ shift 2
+ ;;
+ --lines=*)
+ lines="${1#*=}"
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ die "$(trf unknown_arg "logs" "$1")"
+ ;;
+ esac
+ done
+
+ [[ "${lines}" =~ ^[1-9][0-9]*$ ]] || die "$(trf lines_positive_integer)"
+ require_linux
+ ensure_root_via_sudo logs "${original_args[@]}"
+ require_root
+ require_command journalctl
+ journalctl -u "${SERVICE_NAME}" --no-pager -q -n "${lines}"
+}
+
+prompt_input() {
+ local label="$1"
+ local default_value="${2:-}"
+ local input
+
+ if [[ -n "${default_value}" ]]; then
+ printf '%b%s%b [%s]: ' "${COLOR_BOLD}" "${label}" "${COLOR_RESET}" "${default_value}" >&2
+ else
+ printf '%b%s%b: ' "${COLOR_BOLD}" "${label}" "${COLOR_RESET}" >&2
+ fi
+
+ read -r input || return 1
+ if [[ -z "${input}" ]]; then
+ printf '%s\n' "${default_value}"
+ else
+ printf '%s\n' "${input}"
+ fi
+}
+
+pause_for_menu() {
+ printf '\n'
+ printf '%b%s%b' "${COLOR_DIM}" "$(trf press_enter_continue)" "${COLOR_RESET}"
+ read -r _ || true
+}
+
+print_rule() {
+ local char="${1:--}"
+ local width="${2:-64}"
+ local line
+
+ printf -v line '%*s' "${width}" ''
+ line="${line// /${char}}"
+ printf '%b%s%b\n' "${COLOR_DIM}" "${line}" "${COLOR_RESET}"
+}
+
+format_badge() {
+ local text="$1"
+ local color="${2:-${COLOR_BLUE}}"
+ printf '%b[%s]%b' "${color}${COLOR_BOLD}" "${text}" "${COLOR_RESET}"
+}
+
+format_service_state_badge() {
+ local state="$1"
+ local color="${COLOR_RED}"
+
+ case "${state}" in
+ "$(trf state_active)")
+ color="${COLOR_GREEN}"
+ ;;
+ "$(trf state_inactive)")
+ color="${COLOR_YELLOW}"
+ ;;
+ esac
+
+ format_badge "${state}" "${color}"
+}
+
+format_menu_item_badge() {
+ local kind="$1"
+ local label color
+
+ case "${kind}" in
+ direct)
+ label="$(trf menu_badge_direct)"
+ color="${COLOR_GREEN}"
+ ;;
+ confirm)
+ label="$(trf menu_badge_confirm)"
+ color="${COLOR_YELLOW}"
+ ;;
+ *)
+ return 0
+ ;;
+ esac
+
+ format_badge "${label}" "${color}"
+}
+
+print_menu_meta_line() {
+ local label="$1"
+ local value="$2"
+ printf ' %b%s%b %s\n' "${COLOR_DIM}" "${label}:" "${COLOR_RESET}" "${value}"
+}
+
+print_menu_section_title() {
+ printf '\n%b%s%b\n' "${COLOR_BLUE}${COLOR_BOLD}" "$1" "${COLOR_RESET}"
+}
+
+print_menu_item() {
+ local index="$1"
+ local title="$2"
+ local mode="${3:-}"
+
+ printf ' %b%2s%b. %s' "${COLOR_CYAN}${COLOR_BOLD}" "${index}" "${COLOR_RESET}" "${title}"
+ if [[ -n "${mode}" ]]; then
+ printf ' %s' "$(format_menu_item_badge "${mode}")"
+ fi
+ printf '\n'
+}
+
+print_menu_action_preview() {
+ local title="$1"
+ local description="$2"
+
+ printf '\n' >&2
+ print_rule "-" 64 >&2
+ printf '%b> %s%b\n' "${COLOR_BLUE}${COLOR_BOLD}" "${title}" "${COLOR_RESET}" >&2
+ printf '%s\n' "${description}" >&2
+ print_rule "-" 64 >&2
+}
+
+confirm_menu_action() {
+ local title="$1"
+ local description="$2"
+ local reply
+
+ print_menu_action_preview "${title}" "${description}"
+ printf '%b%s%b' "${COLOR_BOLD}" "$(trf menu_confirm_prompt)" "${COLOR_RESET}" >&2
+ read -r reply || return 1
+ if is_affirmative_reply "${reply}"; then
+ return 0
+ fi
+
+ warn "$(trf menu_cancelled_back)"
+ pause_for_menu
+ return 1
+}
+
+service_state_summary() {
+ if [[ ! -f "${SYSTEMD_UNIT_PATH}" ]]; then
+ printf '%s\n' "$(trf state_not_installed)"
+ return
+ fi
+
+ if ! have_command systemctl; then
+ printf '%s\n' "$(trf state_no_systemctl)"
+ return
+ fi
+
+ if [[ ! -d /run/systemd/system ]]; then
+ printf '%s\n' "$(trf state_no_systemd)"
+ return
+ fi
+
+ if systemctl is-active --quiet "${SERVICE_NAME}" >/dev/null 2>&1; then
+ printf '%s\n' "$(trf state_active)"
+ return
+ fi
+
+ printf '%s\n' "$(trf state_inactive)"
+}
+
+print_menu_header() {
+ local current_lang_label service_state
+
+ if [[ "${ACTIVE_LANG}" == "zh" ]]; then
+ current_lang_label="$(trf language_name_zh)"
+ else
+ current_lang_label="$(trf language_name_en)"
+ fi
+
+ service_state="$(service_state_summary)"
+
+ printf '\n'
+ print_rule "=" 64
+ printf '%b%s%b\n' "${COLOR_CYAN}${COLOR_BOLD}" "$(trf menu_title)" "${COLOR_RESET}"
+ printf '%b%s%b\n' "${COLOR_DIM}" "$(trf menu_subtitle)" "${COLOR_RESET}"
+ print_rule "-" 64
+ print_menu_meta_line "$(trf label_service)" "$(format_service_state_badge "${service_state}")"
+ print_menu_meta_line "$(trf label_install_dir)" "${INSTALL_DIR}"
+ print_menu_meta_line "$(trf label_config)" "${CONFIG_PATH}"
+ print_menu_meta_line "$(trf current_language)" "$(format_badge "${current_lang_label}" "${COLOR_CYAN}")"
+ print_rule "-" 64
+}
+
+print_menu_options() {
+ print_menu_section_title "$(trf menu_section_setup)"
+ print_menu_item "1" "$(trf action_download_title)" "confirm"
+ print_menu_item "2" "$(trf action_install_title)" "confirm"
+ print_menu_item "3" "$(trf action_upgrade_title)" "confirm"
+ print_menu_item "4" "$(trf action_self_install_title)" "confirm"
+
+ print_menu_section_title "$(trf menu_section_runtime)"
+ print_menu_item "5" "$(trf action_show_config_title)" "direct"
+ print_menu_item "6" "$(trf action_init_password_title)" "confirm"
+ print_menu_item "7" "$(trf action_status_title)" "direct"
+ print_menu_item "8" "$(trf action_logs_title)" "direct"
+
+ print_menu_section_title "$(trf menu_section_tools)"
+ print_menu_item "9" "$(trf action_language_title)" "direct"
+ print_menu_item "10" "$(trf action_help_title)" "direct"
+ print_menu_item "0" "$(trf menu_exit)"
+
+ print_rule "-" 64
+ printf '%b%s%b\n' "${COLOR_DIM}" "$(trf menu_hint_direct)" "${COLOR_RESET}"
+ printf '%b%s%b\n' "${COLOR_DIM}" "$(trf menu_hint_confirm)" "${COLOR_RESET}"
+}
+
+run_menu_command() {
+ local rc
+
+ printf '\n'
+ if EPCTL_LANG="${ACTIVE_LANG}" "${SCRIPT_SOURCE_PATH}" --lang "${ACTIVE_LANG}" "$@"; then
+ success "$(trf command_completed)"
+ else
+ rc="$?"
+ warn "$(trf command_failed_exit "${rc}")"
+ fi
+
+ pause_for_menu
+}
+
+interactive_download() {
+ local tag
+
+ confirm_menu_action \
+ "$(trf action_download_title)" \
+ "$(trf action_download_desc "${CACHE_ROOT}")" || return 0
+
+ tag="$(prompt_input "$(trf input_release_tag)")" || return 0
+ if [[ -n "${tag}" ]]; then
+ run_menu_command download --tag "${tag}"
+ else
+ run_menu_command download
+ fi
+}
+
+interactive_install() {
+ local tag app_uri listen
+
+ confirm_menu_action \
+ "$(trf action_install_title)" \
+ "$(trf action_install_desc "${INSTALL_DIR}" "${SYSTEM_USER}" "${SYSTEMD_UNIT_PATH}" "${SERVICE_NAME}")" || return 0
+
+ tag="$(prompt_input "$(trf input_release_tag)")" || return 0
+ app_uri="$(prompt_input "$(trf input_app_uri)" "${DEFAULT_APP_URI}")" || return 0
+ listen="$(prompt_input "$(trf input_http_listen)" "${DEFAULT_LISTEN}")" || return 0
+
+ if [[ -n "${tag}" ]]; then
+ run_menu_command install --tag "${tag}" --app-uri "${app_uri}" --listen "${listen}"
+ else
+ run_menu_command install --app-uri "${app_uri}" --listen "${listen}"
+ fi
+}
+
+interactive_upgrade() {
+ local tag
+
+ confirm_menu_action \
+ "$(trf action_upgrade_title)" \
+ "$(trf action_upgrade_desc "${INSTALL_DIR}" "${SERVICE_NAME}")" || return 0
+
+ tag="$(prompt_input "$(trf input_upgrade_tag)")" || return 0
+ if [[ -n "${tag}" ]]; then
+ run_menu_command upgrade --tag "${tag}"
+ else
+ run_menu_command upgrade
+ fi
+}
+
+interactive_logs() {
+ local lines
+
+ print_menu_action_preview \
+ "$(trf action_logs_title)" \
+ "$(trf action_logs_desc "${SERVICE_NAME}")"
+
+ lines="$(prompt_input "$(trf input_log_lines)" "100")" || return 0
+ run_menu_command logs --lines "${lines}"
+}
+
+interactive_switch_language() {
+ local choice
+
+ print_menu_action_preview \
+ "$(trf action_language_title)" \
+ "$(trf action_language_desc)"
+
+ printf '\n%b%s%b\n' "${COLOR_BOLD}" "$(trf menu_language_prompt)" "${COLOR_RESET}"
+ choice="$(prompt_input "$(trf menu_language_input)")" || return 0
+ case "${choice}" in
+ 1|zh|ZH|cn|CN|中文)
+ set_language "zh"
+ success "$(trf language_switched "$(trf language_name_zh)" "$(trf language_name_zh)")"
+ ;;
+ 2|en|EN|english|English|英文|英语)
+ set_language "en"
+ success "$(trf language_switched "$(trf language_name_en)" "$(trf language_name_en)")"
+ ;;
+ *)
+ warn "$(trf invalid_selection "${choice}")"
+ ;;
+ esac
+ pause_for_menu
+}
+
+interactive_menu() {
+ local choice
+
+ require_linux
+ while true; do
+ print_menu_header
+ print_menu_options
+ printf '%b%s%b' "${COLOR_BOLD}" "$(trf menu_select_action)" "${COLOR_RESET}"
+ read -r choice || return 0
+
+ case "${choice}" in
+ 1)
+ interactive_download
+ ;;
+ 2)
+ interactive_install
+ ;;
+ 3)
+ interactive_upgrade
+ ;;
+ 4)
+ if confirm_menu_action \
+ "$(trf action_self_install_title)" \
+ "$(trf action_self_install_desc "${EPCTL_NAME}" "${EPCTL_NAME} --lang zh" "${EPCTL_NAME} --lang en")"; then
+ run_menu_command self-install
+ fi
+ ;;
+ 5)
+ print_menu_action_preview \
+ "$(trf action_show_config_title)" \
+ "$(trf action_show_config_desc "${CONFIG_PATH}")"
+ run_menu_command show-config
+ ;;
+ 6)
+ if confirm_menu_action \
+ "$(trf action_init_password_title)" \
+ "$(trf action_init_password_desc "${CONFIG_PATH}")"; then
+ run_menu_command init-password
+ fi
+ ;;
+ 7)
+ print_menu_action_preview \
+ "$(trf action_status_title)" \
+ "$(trf action_status_desc "${SERVICE_NAME}")"
+ run_menu_command status
+ ;;
+ 8)
+ interactive_logs
+ ;;
+ 9)
+ interactive_switch_language
+ ;;
+ 10)
+ print_menu_action_preview \
+ "$(trf action_help_title)" \
+ "$(trf action_help_desc)"
+ usage
+ pause_for_menu
+ ;;
+ 0|q|Q|quit|QUIT|exit|EXIT)
+ return 0
+ ;;
+ *)
+ warn "$(trf invalid_selection "${choice}")"
+ pause_for_menu
+ ;;
+ esac
+ done
+}
+
+main() {
+ local command="${1:-}"
+ shift || true
+
+ case "${command}" in
+ zh|zh-cn|zh_cn|cn|中文)
+ set_language "zh"
+ if [[ $# -gt 0 ]]; then
+ main "$@"
+ elif is_interactive_terminal; then
+ interactive_menu
+ else
+ usage
+ fi
+ ;;
+ en|en-us|en_us|english)
+ set_language "en"
+ if [[ $# -gt 0 ]]; then
+ main "$@"
+ elif is_interactive_terminal; then
+ interactive_menu
+ else
+ usage
+ fi
+ ;;
+ menu|interactive)
+ interactive_menu
+ ;;
+ download)
+ command_download "$@"
+ ;;
+ install)
+ command_install "$@"
+ ;;
+ upgrade)
+ command_upgrade "$@"
+ ;;
+ self-install)
+ command_self_install "$@"
+ ;;
+ show-config)
+ command_show_config "$@"
+ ;;
+ init-password)
+ command_init_password "$@"
+ ;;
+ status)
+ command_status "$@"
+ ;;
+ logs)
+ command_logs "$@"
+ ;;
+ -h|--help|help)
+ usage
+ ;;
+ "")
+ if is_interactive_terminal; then
+ interactive_menu
+ else
+ usage
+ exit 1
+ fi
+ ;;
+ *)
+ die "$(trf unknown_command "${command}")"
+ ;;
+ esac
+}
+
+set_language ""
+parse_global_options "$@"
+init_colors
+main "${POSITIONAL_ARGS[@]}"
diff --git a/epctl-docker-test.sh b/epctl-docker-test.sh
new file mode 100755
index 0000000..2a663fb
--- /dev/null
+++ b/epctl-docker-test.sh
@@ -0,0 +1,379 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+readonly IMAGE_TAG="epctl-ubuntu-systemd-test:$(date +%s)-$$"
+readonly CONTAINER_NAME="epctl-systemd-test-$$"
+readonly BASE_IMAGE="ubuntu:24.04"
+
+ACTIVE_LANG=""
+POSITIONAL_ARGS=()
+
+detect_default_language() {
+ local raw normalized
+
+ raw="${EPCTL_LANG:-}"
+ if [[ -z "${raw}" ]]; then
+ printf 'zh\n'
+ return
+ fi
+
+ normalized="${raw,,}"
+ case "${normalized}" in
+ zh|zh-*|zh_*|cn|chs|cht|中文)
+ printf 'zh\n'
+ ;;
+ en|en-*|en_*|english)
+ printf 'en\n'
+ ;;
+ *)
+ printf '%s\n' "${raw}"
+ ;;
+ esac
+}
+
+set_language() {
+ local requested normalized fallback_lang
+
+ requested="${1:-$(detect_default_language)}"
+ normalized="${requested,,}"
+ fallback_lang="${ACTIVE_LANG:-$(detect_default_language)}"
+ case "${normalized}" in
+ zh|zh-*|zh_*|cn|chs|cht|中文)
+ ACTIVE_LANG="zh"
+ ;;
+ en|en-*|en_*|english)
+ ACTIVE_LANG="en"
+ ;;
+ *)
+ ACTIVE_LANG="${fallback_lang}"
+ die "$(trf unsupported_language "${requested}")"
+ ;;
+ esac
+
+ export EPCTL_LANG="${ACTIVE_LANG}"
+}
+
+trf() {
+ local key="$1"
+ shift || true
+ local template
+
+ case "${ACTIVE_LANG}:${key}" in
+ en:usage) template='Usage:' ;;
+ zh:usage) template='用法:' ;;
+ en:example) template='Example:' ;;
+ zh:example) template='示例:' ;;
+ en:missing_dependency) template='missing dependency: %s' ;;
+ zh:missing_dependency) template='缺少依赖:%s' ;;
+ en:arg_requires_value) template='%s requires a value' ;;
+ zh:arg_requires_value) template='%s 需要一个参数值' ;;
+ en:unsupported_language) template='unsupported language: %s' ;;
+ zh:unsupported_language) template='不支持的语言:%s' ;;
+ en:unsupported_arch) template='unsupported architecture: %s' ;;
+ zh:unsupported_arch) template='不支持的架构:%s' ;;
+ en:test_failed_collecting) template='test failed; collecting container diagnostics' ;;
+ zh:test_failed_collecting) template='测试失败,正在收集容器诊断信息' ;;
+ en:install_tag_invalid) template='install tag must be a real GitHub release tag such as v1.0.6' ;;
+ zh:install_tag_invalid) template='安装 tag 必须是真实的 GitHub release tag,例如 v1.0.6' ;;
+ en:upgrade_tag_invalid) template='upgrade tag must be a real GitHub release tag such as v1.0.6' ;;
+ zh:upgrade_tag_invalid) template='升级 tag 必须是真实的 GitHub release tag,例如 v1.0.6' ;;
+ en:base_image) template='base image: %s' ;;
+ zh:base_image) template='基础镜像:%s' ;;
+ en:building_image) template='building the Ubuntu systemd test image' ;;
+ zh:building_image) template='正在构建 Ubuntu systemd 测试镜像' ;;
+ en:starting_container) template='starting the test container' ;;
+ zh:starting_container) template='正在启动测试容器' ;;
+ en:systemd_not_ready) template='systemd did not become ready inside the container' ;;
+ zh:systemd_not_ready) template='容器内的 systemd 未能及时就绪' ;;
+ en:self_installing) template='installing epctl into PATH' ;;
+ zh:self_installing) template='正在将 epctl 安装到 PATH' ;;
+ en:downloading_release) template='downloading release %s' ;;
+ zh:downloading_release) template='正在下载 release %s' ;;
+ en:installing_release) template='installing epusdt from release %s' ;;
+ zh:installing_release) template='正在从 release %s 安装 epusdt' ;;
+ en:waiting_assets) template='waiting for epusdt to start and release www assets' ;;
+ zh:waiting_assets) template='正在等待 epusdt 启动并释放 www 静态文件' ;;
+ en:upgrading_release) template='upgrading epusdt from %s to %s' ;;
+ zh:upgrading_release) template='正在将 epusdt 从 %s 升级到 %s' ;;
+ en:checking_init_password) template='checking init-password behavior' ;;
+ zh:checking_init_password) template='正在检查 init-password 行为' ;;
+ en:expected_second_failure) template='expected init-password to fail after the password was changed' ;;
+ zh:expected_second_failure) template='密码修改后,init-password 本应失败,但它却成功了' ;;
+ en:verification_succeeded) template='docker verification succeeded for install=%s%s' ;;
+ zh:verification_succeeded) template='Docker 验证通过,install=%s%s' ;;
+ *)
+ template="missing translation: ${key}"
+ ;;
+ esac
+
+ printf -- "${template}" "$@"
+}
+
+log() {
+ printf '[epctl-docker-test] %s\n' "$*" >&2
+}
+
+die() {
+ printf '[epctl-docker-test] error: %s\n' "$*" >&2
+ exit 1
+}
+
+usage() {
+ if [[ "${ACTIVE_LANG}" == "zh" ]]; then
+ cat <<'EOF'
+用法:
+ ./epctl-docker-test.sh [--lang zh|en] [upgrade-tag]
+
+示例:
+ ./epctl-docker-test.sh v1.0.6
+ ./epctl-docker-test.sh --lang zh v1.0.6 v1.0.8
+EOF
+ else
+ cat <<'EOF'
+Usage:
+ ./epctl-docker-test.sh [--lang zh|en] [upgrade-tag]
+
+Example:
+ ./epctl-docker-test.sh v1.0.6
+ ./epctl-docker-test.sh --lang en v1.0.6 v1.0.8
+EOF
+ fi
+}
+
+parse_global_options() {
+ POSITIONAL_ARGS=()
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --lang)
+ [[ $# -ge 2 ]] || die "$(trf arg_requires_value "--lang")"
+ set_language "$2"
+ shift 2
+ ;;
+ --lang=*)
+ set_language "${1#*=}"
+ shift
+ ;;
+ *)
+ POSITIONAL_ARGS+=("$1")
+ shift
+ ;;
+ esac
+ done
+}
+
+require_command() {
+ command -v "$1" >/dev/null 2>&1 || die "$(trf missing_dependency "$1")"
+}
+
+map_arch() {
+ case "$(uname -m)" in
+ x86_64|amd64)
+ printf 'amd64\n'
+ ;;
+ aarch64|arm64)
+ printf 'arm64\n'
+ ;;
+ *)
+ die "$(trf unsupported_arch "$(uname -m)")"
+ ;;
+ esac
+}
+
+docker_bash() {
+ docker exec "${CONTAINER_NAME}" bash -lc "$1"
+}
+
+cleanup() {
+ local exit_code="$?"
+ if [[ "${exit_code}" -ne 0 ]]; then
+ log "$(trf test_failed_collecting)"
+ docker exec "${CONTAINER_NAME}" bash -lc 'systemctl status epusdt --no-pager || true' || true
+ docker exec "${CONTAINER_NAME}" bash -lc 'journalctl -u epusdt --no-pager -n 100 || true' || true
+ fi
+ docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
+ docker rmi -f "${IMAGE_TAG}" >/dev/null 2>&1 || true
+ rm -rf "${TMPDIR_PATH:-}"
+}
+
+wait_for_systemd() {
+ local attempt
+ for attempt in $(seq 1 30); do
+ if docker exec "${CONTAINER_NAME}" bash -lc 'systemctl show --property=Version --value >/dev/null 2>&1'; then
+ return 0
+ fi
+ sleep 1
+ done
+ return 1
+}
+
+set_language ""
+parse_global_options "$@"
+
+INSTALL_TAG="${POSITIONAL_ARGS[0]:-}"
+UPGRADE_TAG="${POSITIONAL_ARGS[1]:-}"
+[[ -n "${INSTALL_TAG}" ]] || {
+ usage
+ exit 1
+}
+[[ "${INSTALL_TAG}" == v* ]] || die "$(trf install_tag_invalid)"
+if [[ -n "${UPGRADE_TAG}" && "${UPGRADE_TAG}" != v* ]]; then
+ die "$(trf upgrade_tag_invalid)"
+fi
+
+require_command docker
+require_command curl
+
+ASSET_ARCH="$(map_arch)"
+ASSET_URL="https://github.com/GMWalletApp/epusdt/releases/download/${INSTALL_TAG}/epusdt-${INSTALL_TAG#v}-linux-${ASSET_ARCH}.tar.gz"
+SUMS_URL="https://github.com/GMWalletApp/epusdt/releases/download/${INSTALL_TAG}/SHA256SUMS"
+
+curl --fail --silent --show-error --head "${ASSET_URL}" >/dev/null
+curl --fail --silent --show-error --head "${SUMS_URL}" >/dev/null
+if [[ -n "${UPGRADE_TAG}" ]]; then
+ curl --fail --silent --show-error --head "https://github.com/GMWalletApp/epusdt/releases/download/${UPGRADE_TAG}/epusdt-${UPGRADE_TAG#v}-linux-${ASSET_ARCH}.tar.gz" >/dev/null
+ curl --fail --silent --show-error --head "https://github.com/GMWalletApp/epusdt/releases/download/${UPGRADE_TAG}/SHA256SUMS" >/dev/null
+fi
+
+TMPDIR_PATH="$(mktemp -d)"
+trap cleanup EXIT
+
+cat > "${TMPDIR_PATH}/Dockerfile" < /etc/sudoers.d/tester \
+ && chmod 0440 /etc/sudoers.d/tester
+
+STOPSIGNAL SIGRTMIN+3
+
+CMD ["/sbin/init"]
+EOF
+
+log "$(trf base_image "${BASE_IMAGE}")"
+log "$(trf building_image)"
+docker build -t "${IMAGE_TAG}" "${TMPDIR_PATH}" >/dev/null
+
+log "$(trf starting_container)"
+docker run -d \
+ --name "${CONTAINER_NAME}" \
+ --privileged \
+ --cgroupns=host \
+ -e container=docker \
+ --tmpfs /run \
+ --tmpfs /run/lock \
+ -v /sys/fs/cgroup:/sys/fs/cgroup:rw \
+ -v "${SCRIPT_DIR}:/work" \
+ -w /work \
+ "${IMAGE_TAG}" \
+ /sbin/init >/dev/null
+
+wait_for_systemd || die "$(trf systemd_not_ready)"
+
+log "$(trf self_installing)"
+docker exec "${CONTAINER_NAME}" bash -lc "su - tester -c 'cd /work && ./epctl self-install'"
+docker_bash '[[ -x /usr/local/bin/epctl ]]'
+docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "EPCTL_NO_COLOR=1 epctl help | grep -q \"^用法:\""'
+docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang zh help | grep -q \"^用法:\""'
+docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en help | grep -q \"^Usage:\""'
+
+log "$(trf downloading_release "${INSTALL_TAG}")"
+docker exec "${CONTAINER_NAME}" env TAG="${INSTALL_TAG}" bash -lc 'su - tester -c "epctl download --tag ${TAG}"'
+
+log "$(trf installing_release "${INSTALL_TAG}")"
+docker exec "${CONTAINER_NAME}" env TAG="${INSTALL_TAG}" bash -lc 'su - tester -c "epctl install --tag ${TAG} --app-uri http://127.0.0.1:8000"'
+
+log "$(trf waiting_assets)"
+docker exec "${CONTAINER_NAME}" bash -lc '
+for _ in $(seq 1 30); do
+ if systemctl is-active --quiet epusdt && [[ -f /opt/epusdt/www/index.html ]]; then
+ exit 0
+ fi
+ sleep 1
+done
+systemctl status epusdt --no-pager || true
+journalctl -u epusdt --no-pager -n 100 || true
+exit 1
+'
+
+docker_bash '[[ -x /opt/epusdt/epusdt ]]'
+docker_bash '[[ -f /opt/epusdt/.env ]]'
+docker_bash 'systemctl is-active --quiet epusdt'
+docker_bash '[[ -f /opt/epusdt/www/index.html ]]'
+docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "epctl show-config | grep -q \"^install=false$\""'
+docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang zh show-config | grep -q \"^install=false$\""'
+docker exec "${CONTAINER_NAME}" bash -lc '
+su - tester -c "epctl status >/tmp/epctl-status.out 2>/tmp/epctl-status.err"
+! grep -q "systemd-journal" /tmp/epctl-status.err
+'
+docker exec "${CONTAINER_NAME}" bash -lc '
+su - tester -c "epctl logs --lines 50 >/tmp/epctl-logs.out 2>/tmp/epctl-logs.err"
+! grep -q "systemd-journal" /tmp/epctl-logs.err
+'
+
+if [[ -n "${UPGRADE_TAG}" ]]; then
+ log "$(trf upgrading_release "${INSTALL_TAG}" "${UPGRADE_TAG}")"
+ docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -lc 'su - tester -c "epctl upgrade --tag ${TAG}"'
+ docker exec "${CONTAINER_NAME}" bash -lc '
+for _ in $(seq 1 30); do
+ if systemctl is-active --quiet epusdt && [[ -f /opt/epusdt/www/index.html ]]; then
+ exit 0
+ fi
+ sleep 1
+done
+systemctl status epusdt --no-pager || true
+journalctl -u epusdt --no-pager -n 100 || true
+exit 1
+'
+ docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "epctl show-config | grep -q \"^install=false$\""'
+ docker exec "${CONTAINER_NAME}" bash -lc '
+su - tester -c "epctl status >/tmp/epctl-status-upgrade.out 2>/tmp/epctl-status-upgrade.err"
+! grep -q "systemd-journal" /tmp/epctl-status-upgrade.err
+'
+fi
+
+log "$(trf checking_init_password)"
+docker exec "${CONTAINER_NAME}" bash -lc '
+response="$(su - tester -c "epctl init-password")"
+password="$(printf "%s\n" "${response}" | jq -r ".data.password")"
+printf "%s\n" "${response}" | jq -e ".status_code == 200 and (.data.password | type == \"string\" and length > 0)" >/dev/null
+
+login_payload="$(jq -nc --arg username admin --arg password "${password}" "{username:\$username,password:\$password}")"
+token="$(curl --fail --silent --show-error \
+ -H "Content-Type: application/json" \
+ -d "${login_payload}" \
+ http://127.0.0.1:8000/admin/api/v1/auth/login | jq -r ".data.token")"
+[[ -n "${token}" && "${token}" != "null" ]]
+
+change_payload="$(jq -nc --arg old_password "${password}" --arg new_password "new-pass-789" "{old_password:\$old_password,new_password:\$new_password}")"
+curl --fail --silent --show-error \
+ -H "Authorization: Bearer ${token}" \
+ -H "Content-Type: application/json" \
+ -d "${change_payload}" \
+ http://127.0.0.1:8000/admin/api/v1/auth/password | jq -e ".status_code == 200" >/dev/null
+
+failure_output="$(mktemp)"
+if su - tester -c "epctl init-password" >"${failure_output}" 2>&1; then
+ cat "${failure_output}" >&2
+ rm -f "${failure_output}"
+ echo "'"$(trf expected_second_failure)"'" >&2
+ exit 1
+fi
+grep -Eq "\"status_code\"[[:space:]]*:[[:space:]]*10040" "${failure_output}"
+rm -f "${failure_output}"
+'
+
+log "$(trf verification_succeeded "${INSTALL_TAG}" "${UPGRADE_TAG:+ upgrade=${UPGRADE_TAG}}")"
diff --git a/wiki/EPCTL.en.md b/wiki/EPCTL.en.md
new file mode 100644
index 0000000..3e1ab9b
--- /dev/null
+++ b/wiki/EPCTL.en.md
@@ -0,0 +1,213 @@
+# epctl Installation and Verification Scripts
+
+`epctl` is the repo-root Linux binary installer and service manager for released `epusdt` binaries on GitHub Releases.
+`epctl-docker-test.sh` is the matching end-to-end validation script. It launches Ubuntu + systemd in Docker and verifies download, install, startup, upgrade, and init-password behavior with real release artifacts.
+
+## Scope
+
+- Linux only
+- Binary installation only
+- Release source is fixed to `https://github.com/GMWalletApp/epusdt/releases`
+- Service management is based on systemd
+
+## Dependencies and Privileges
+
+`epctl` expects these commands:
+
+- `curl`
+- `tar`
+- `systemctl`
+- `install`
+- `grep`
+- `sed`
+
+Notes:
+
+- `install`, `upgrade`, and `self-install` write into `/opt`, `/etc/systemd`, and `/usr/local/bin`
+- `status` and `logs` automatically re-run through `sudo` when needed
+- in practice, the operator should have `sudo` access
+
+## Fixed Paths
+
+| Item | Path |
+|------|------|
+| Install directory | `/opt/epusdt` |
+| Main binary | `/opt/epusdt/epusdt` |
+| Active config | `/opt/epusdt/.env` |
+| Example config | `/opt/epusdt/.env.example` |
+| Extracted frontend assets | `/opt/epusdt/www` |
+| Download cache | `/tmp/epusdt//` |
+| systemd unit | `/etc/systemd/system/epusdt.service` |
+| Global epctl path | `/usr/local/bin/epctl` |
+
+## Quick Start
+
+Run the interactive menu from the repo root:
+
+```bash
+./epctl
+```
+
+The default interactive language is Chinese. You can force either language:
+
+```bash
+./epctl zh
+./epctl en
+./epctl --lang zh help
+./epctl --lang en help
+```
+
+Install the script into PATH:
+
+```bash
+./epctl self-install
+epctl
+```
+
+## Common Commands
+
+Download a specific release:
+
+```bash
+./epctl download --tag v1.0.8
+```
+
+Install the service:
+
+```bash
+./epctl install --tag v1.0.8 \
+ --app-uri https://pay.example.com \
+ --listen 127.0.0.1:18000
+```
+
+Upgrade to a newer release:
+
+```bash
+./epctl upgrade --tag v1.0.9
+```
+
+Inspect config, status, and logs:
+
+```bash
+./epctl show-config
+./epctl status
+./epctl logs --lines 200
+```
+
+Request the initial admin password:
+
+```bash
+./epctl init-password
+```
+
+## Behavior When `--tag` Is Omitted
+
+For `download`, `install`, and `upgrade`, `epctl` resolves the current latest GitHub release tag first, then shows the exact tag and asks for confirmation.
+
+Example:
+
+```bash
+./epctl install --app-uri https://pay.example.com
+```
+
+In interactive use, the script will display the resolved latest tag before continuing.
+For automation, passing `--tag` explicitly is preferred. If you intentionally want to skip the confirmation, use:
+
+```bash
+EPCTL_ASSUME_YES=1 ./epctl download
+```
+
+## What Happens on First Install
+
+`install` performs these steps:
+
+1. Detect the current CPU architecture and download the matching GitHub Release archive
+2. Extract into `/tmp/epusdt//extract/`
+3. Install the binary to `/opt/epusdt/epusdt`
+4. Install `.env.example` to `/opt/epusdt/.env.example`
+5. Create the system user and group `epusdt`
+6. Auto-create `/opt/epusdt/.env` from `.env.example` if it does not exist
+7. Write and enable `epusdt.service`
+
+When `.env` is auto-created, the script applies only the minimum bootstrap changes:
+
+- `install=false`
+- `app_uri=<--app-uri, default http://127.0.0.1:8000>`
+- `http_listen=<--listen, default 127.0.0.1:8000>`
+
+If `/opt/epusdt/.env` already exists, install and upgrade keep it unchanged.
+
+## systemd Service Details
+
+The service name is fixed to `epusdt.service` and uses these core settings:
+
+```ini
+WorkingDirectory=/opt/epusdt
+ExecStart=/opt/epusdt/epusdt http start
+User=epusdt
+Group=epusdt
+Restart=always
+RestartSec=3
+```
+
+The working directory stays at `/opt/epusdt` because the program extracts its `www/` assets next to the binary.
+
+## What `init-password` Does
+
+`epctl init-password` only calls the local HTTP route:
+
+```text
+GET /admin/api/v1/auth/init-password
+```
+
+It does not read the database directly.
+
+The script reads `http_listen` from `/opt/epusdt/.env` and normalizes these listen values into a local request target:
+
+- `:8000` -> `127.0.0.1:8000`
+- `0.0.0.0:8000` -> `127.0.0.1:8000`
+
+If the API returns `10040`, the bootstrap plaintext password is no longer available. Common reasons are:
+
+- the admin password has already been changed
+- the initial password has already been consumed and cannot be fetched again
+
+In that case, `epctl` prints the original HTTP error body for diagnosis.
+
+## Docker Validation Script
+
+The repo also provides:
+
+```bash
+./epctl-docker-test.sh [upgrade-tag]
+```
+
+Examples:
+
+```bash
+./epctl-docker-test.sh v1.0.6
+./epctl-docker-test.sh --lang en v1.0.6 v1.0.8
+```
+
+It performs these checks on the local machine:
+
+- builds an `ubuntu:24.04` + systemd test image
+- starts a privileged container
+- runs `epctl self-install` inside the container
+- downloads real GitHub Release artifacts
+- installs `epusdt`
+- verifies the systemd service, `www/index.html`, config output, logs, and status
+- verifies that `init-password` succeeds once and later returns `10040` after the admin password is changed
+
+Requirements:
+
+- Docker installed locally
+- permission to run Docker
+- outbound access to GitHub Releases
+
+## Recommendations
+
+- Prefer explicit `--tag` values in automation
+- After installation, run `./epctl show-config` once to verify the active `.env`
+- Change the admin password immediately after retrieving the initial password
+- If you want to validate the installer itself, run `./epctl-docker-test.sh` first
diff --git a/wiki/EPCTL.md b/wiki/EPCTL.md
new file mode 100644
index 0000000..e58dcb8
--- /dev/null
+++ b/wiki/EPCTL.md
@@ -0,0 +1,213 @@
+# epctl 安装与验证脚本
+
+`epctl` 是仓库顶层的 Linux 二进制安装管理脚本,面向已经发布到 GitHub Releases 的 `epusdt` 二进制包。
+`epctl-docker-test.sh` 是配套的真实验收脚本,用本机 Docker 启动 Ubuntu + systemd 容器,完整验证安装、启动、升级和初始化密码流程。
+
+## 适用范围
+
+- 仅支持 Linux
+- 仅支持二进制安装
+- 安装源固定为 `https://github.com/GMWalletApp/epusdt/releases`
+- 默认通过 systemd 管理服务
+
+## 依赖与权限
+
+`epctl` 依赖这些基础命令:
+
+- `curl`
+- `tar`
+- `systemctl`
+- `install`
+- `grep`
+- `sed`
+
+其中:
+
+- `install`、`upgrade`、`self-install` 需要写入 `/opt`、`/etc/systemd`、`/usr/local/bin`
+- `status`、`logs` 会在需要时自动通过 `sudo` 重新执行
+- 所以日常使用建议当前用户具备 `sudo` 权限
+
+## 固定路径
+
+| 项目 | 路径 |
+|------|------|
+| 安装目录 | `/opt/epusdt` |
+| 主程序 | `/opt/epusdt/epusdt` |
+| 配置文件 | `/opt/epusdt/.env` |
+| 示例配置 | `/opt/epusdt/.env.example` |
+| 前端释放目录 | `/opt/epusdt/www` |
+| 下载缓存 | `/tmp/epusdt//` |
+| systemd unit | `/etc/systemd/system/epusdt.service` |
+| epctl 全局安装位置 | `/usr/local/bin/epctl` |
+
+## 快速开始
+
+直接在仓库根目录运行交互菜单:
+
+```bash
+./epctl
+```
+
+默认优先进入中文界面。你也可以显式指定语言:
+
+```bash
+./epctl zh
+./epctl en
+./epctl --lang zh help
+./epctl --lang en help
+```
+
+如果想把脚本装进 PATH:
+
+```bash
+./epctl self-install
+epctl
+```
+
+## 常用命令
+
+下载指定版本:
+
+```bash
+./epctl download --tag v1.0.8
+```
+
+安装服务:
+
+```bash
+./epctl install --tag v1.0.8 \
+ --app-uri https://pay.example.com \
+ --listen 127.0.0.1:18000
+```
+
+升级到新版本:
+
+```bash
+./epctl upgrade --tag v1.0.9
+```
+
+查看配置、状态、日志:
+
+```bash
+./epctl show-config
+./epctl status
+./epctl logs --lines 200
+```
+
+请求初始化管理员密码:
+
+```bash
+./epctl init-password
+```
+
+## 不传 `--tag` 时的行为
+
+`download`、`install`、`upgrade` 在未传 `--tag` 时,会先调用 GitHub API 解析当前 latest release tag,再向用户显示实际 tag 并确认。
+
+例如:
+
+```bash
+./epctl install --app-uri https://pay.example.com
+```
+
+交互模式下会先提示检测到的最新 tag。
+非交互脚本执行时,建议显式传入 `--tag`。如果你明确要跳过确认,可以设置:
+
+```bash
+EPCTL_ASSUME_YES=1 ./epctl download
+```
+
+## 首次安装时会发生什么
+
+执行 `install` 时,脚本会:
+
+1. 按当前机器架构下载 GitHub Release 压缩包
+2. 解压到 `/tmp/epusdt//extract/`
+3. 安装二进制到 `/opt/epusdt/epusdt`
+4. 安装 `.env.example` 到 `/opt/epusdt/.env.example`
+5. 创建系统用户和组 `epusdt`
+6. 若 `/opt/epusdt/.env` 不存在,则从 `.env.example` 自动生成
+7. 写入并启用 `epusdt.service`
+
+自动生成 `.env` 时,脚本只会补默认上线所需的最小改动:
+
+- `install=false`
+- `app_uri=<--app-uri,默认 http://127.0.0.1:8000>`
+- `http_listen=<--listen,默认 127.0.0.1:8000>`
+
+如果 `/opt/epusdt/.env` 已存在,则安装和升级都会保留它,不会覆盖。
+
+## systemd 服务说明
+
+脚本注册的服务名固定为 `epusdt.service`,核心参数如下:
+
+```ini
+WorkingDirectory=/opt/epusdt
+ExecStart=/opt/epusdt/epusdt http start
+User=epusdt
+Group=epusdt
+Restart=always
+RestartSec=3
+```
+
+`WorkingDirectory` 固定为 `/opt/epusdt`,因为程序会在二进制同级目录释放 `www/` 静态文件。
+
+## `init-password` 的含义
+
+`epctl init-password` 只会请求本地 HTTP 路由:
+
+```text
+GET /admin/api/v1/auth/init-password
+```
+
+它不会直接读数据库。
+
+脚本会从 `/opt/epusdt/.env` 解析 `http_listen`,然后自动把这些监听写法转成本地可请求地址:
+
+- `:8000` -> `127.0.0.1:8000`
+- `0.0.0.0:8000` -> `127.0.0.1:8000`
+
+如果接口返回 `10040`,含义是初始化明文密码已经不可用。常见原因是:
+
+- 管理员已经登录并修改过密码
+- 初始化密码已经被消费,当前不再允许再次取回
+
+此时脚本会直接把接口原始错误输出出来,方便排查。
+
+## Docker 验收脚本
+
+仓库顶层提供:
+
+```bash
+./epctl-docker-test.sh [upgrade-tag]
+```
+
+示例:
+
+```bash
+./epctl-docker-test.sh v1.0.6
+./epctl-docker-test.sh --lang zh v1.0.6 v1.0.8
+```
+
+它会在本机:
+
+- 构建 `ubuntu:24.04` + systemd 测试镜像
+- 启动一个特权容器
+- 在容器内执行 `epctl self-install`
+- 下载真实 GitHub Release
+- 安装 `epusdt`
+- 检查 `systemd` 服务、`www/index.html`、配置文件、日志、状态输出
+- 验证 `init-password` 首次成功、修改管理员密码后再次请求返回 `10040`
+
+运行前提:
+
+- 本机已安装 Docker
+- 当前用户有权限执行 Docker
+- 宿主机能够访问 GitHub Releases
+
+## 建议
+
+- 自动化部署场景优先显式传 `--tag`
+- 生产环境建议安装完成后先执行一次 `./epctl show-config`
+- 首次拿到初始化密码后,建议立即登录后台修改管理员密码
+- 如果只是验证脚本是否可用,优先跑 `./epctl-docker-test.sh`