From b23b1ba19475c580d79a3968b64f47994b908f5d Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 28 May 2026 09:04:24 +0800 Subject: [PATCH] Localize tntctl help and diagnostics --- Makefile | 2 +- docs/CHANGELOG.md | 2 + src/tntctl.c | 176 +++++++++++++++++++++++++++++++-------- tests/test_tntctl_cli.sh | 27 ++++++ 4 files changed, 172 insertions(+), 35 deletions(-) diff --git a/Makefile b/Makefile index a7de198..162d177 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o) DEPS = $(OBJECTS:.o=.d) $(CTL_OBJECTS:.o=.d) TARGET = tnt CTL_TARGET = tntctl -CTL_OBJECTS = $(OBJ_DIR)/tntctl.o $(OBJ_DIR)/exec_catalog.o $(OBJ_DIR)/common.o +CTL_OBJECTS = $(OBJ_DIR)/tntctl.o $(OBJ_DIR)/exec_catalog.o $(OBJ_DIR)/common.o $(OBJ_DIR)/i18n.o TARGETS = $(TARGET) $(CTL_TARGET) PREFIX ?= /usr/local diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6889382..8e4f63a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -107,6 +107,8 @@ - `tntctl` now reuses the SSH exec command matcher for local command validation, so `tntctl host --help` reaches the server-side exec help alias instead of being rejected locally. +- `tntctl` local help and local validation errors now follow `TNT_LANG` and + locale selection, matching the server CLI's i18n behavior. - Split UI-language parsing from localized text lookup: `src/i18n.c` now owns locale/code parsing, while `src/i18n_text.c` owns the table-driven text catalog with coverage checks for every message ID. diff --git a/src/tntctl.c b/src/tntctl.c index 673e8b8..20f4590 100644 --- a/src/tntctl.c +++ b/src/tntctl.c @@ -1,5 +1,6 @@ #include "common.h" #include "exec_catalog.h" +#include "i18n.h" #include #include @@ -7,21 +8,126 @@ #include #include -static void print_usage(FILE *stream) { - fprintf(stream, - "Usage: tntctl [options] host command [args...]\n" - "\n" - "Options:\n" - " -p, --port PORT SSH port (default: 2222)\n" - " -l, --login USER SSH login name for exec identity\n" - " --host-key-checking MODE\n" - " OpenSSH host-key mode: yes, accept-new, no\n" - " --known-hosts FILE OpenSSH known_hosts file\n" - " -V, --version Print version and exit\n" - " -h, --help Print this help and exit\n" - "\n" - "Commands mirror the TNT SSH exec interface: health, stats, users,\n" - "tail, dump, post, help, and exit.\n"); +typedef enum { + TNTCTL_TEXT_USAGE, + TNTCTL_TEXT_INVALID_PORT, + TNTCTL_TEXT_INVALID_LOGIN, + TNTCTL_TEXT_INVALID_HOST_KEY_MODE, + TNTCTL_TEXT_INVALID_KNOWN_HOSTS, + TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT, + TNTCTL_TEXT_MISSING_HOST, + TNTCTL_TEXT_INVALID_HOST, + TNTCTL_TEXT_LOGIN_HOST_CONFLICT, + TNTCTL_TEXT_UNKNOWN_COMMAND, + TNTCTL_TEXT_INVALID_REMOTE_COMMAND, + TNTCTL_TEXT_DESTINATION_TOO_LONG, + TNTCTL_TEXT_INVALID_DESTINATION, + TNTCTL_TEXT_OUT_OF_MEMORY, + TNTCTL_TEXT_HOST_KEY_OPTION_TOO_LONG, + TNTCTL_TEXT_KNOWN_HOSTS_OPTION_TOO_LONG, + TNTCTL_TEXT_COUNT +} tntctl_text_id_t; + +static const i18n_string_t tntctl_text_catalog[TNTCTL_TEXT_COUNT] = { + [TNTCTL_TEXT_USAGE] = I18N_STRING( + "Usage: tntctl [options] host command [args...]\n" + "\n" + "Options:\n" + " -p, --port PORT SSH port (default: 2222)\n" + " -l, --login USER SSH login name for exec identity\n" + " --host-key-checking MODE\n" + " OpenSSH host-key mode: yes, accept-new, no\n" + " --known-hosts FILE OpenSSH known_hosts file\n" + " -V, --version Print version and exit\n" + " -h, --help Print this help and exit\n" + "\n" + "Commands mirror the TNT SSH exec interface: health, stats, users,\n" + "tail, dump, post, help, and exit.\n", + "用法: tntctl [options] host command [args...]\n" + "\n" + "选项:\n" + " -p, --port PORT SSH 端口 (默认: 2222)\n" + " -l, --login USER SSH 登录名,用作 exec 身份\n" + " --host-key-checking MODE\n" + " OpenSSH 主机密钥模式: yes, accept-new, no\n" + " --known-hosts FILE OpenSSH known_hosts 文件\n" + " -V, --version 输出版本并退出\n" + " -h, --help 输出此帮助并退出\n" + "\n" + "命令对应 TNT SSH exec 接口: health, stats, users,\n" + "tail, dump, post, help 和 exit.\n" + ), + [TNTCTL_TEXT_INVALID_PORT] = I18N_STRING( + "invalid port", "端口无效" + ), + [TNTCTL_TEXT_INVALID_LOGIN] = I18N_STRING( + "invalid login", "登录名无效" + ), + [TNTCTL_TEXT_INVALID_HOST_KEY_MODE] = I18N_STRING( + "invalid host-key checking mode", "主机密钥检查模式无效" + ), + [TNTCTL_TEXT_INVALID_KNOWN_HOSTS] = I18N_STRING( + "invalid known_hosts path", "known_hosts 路径无效" + ), + [TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT] = I18N_STRING( + "unknown option: %s", "未知选项: %s" + ), + [TNTCTL_TEXT_MISSING_HOST] = I18N_STRING( + "missing host", "缺少 host" + ), + [TNTCTL_TEXT_INVALID_HOST] = I18N_STRING( + "invalid host", "host 无效" + ), + [TNTCTL_TEXT_LOGIN_HOST_CONFLICT] = I18N_STRING( + "use either --login or user@host, not both", + "只能使用 --login 或 user@host 之一" + ), + [TNTCTL_TEXT_UNKNOWN_COMMAND] = I18N_STRING( + "unknown or missing command", "未知命令或缺少命令" + ), + [TNTCTL_TEXT_INVALID_REMOTE_COMMAND] = I18N_STRING( + "invalid or too-long command", "命令无效或过长" + ), + [TNTCTL_TEXT_DESTINATION_TOO_LONG] = I18N_STRING( + "destination too long", "目标地址过长" + ), + [TNTCTL_TEXT_INVALID_DESTINATION] = I18N_STRING( + "invalid destination", "目标地址无效" + ), + [TNTCTL_TEXT_OUT_OF_MEMORY] = I18N_STRING( + "out of memory", "内存不足" + ), + [TNTCTL_TEXT_HOST_KEY_OPTION_TOO_LONG] = I18N_STRING( + "host-key option too long", "主机密钥选项过长" + ), + [TNTCTL_TEXT_KNOWN_HOSTS_OPTION_TOO_LONG] = I18N_STRING( + "known_hosts option too long", "known_hosts 选项过长" + ) +}; +typedef char tntctl_text_catalog_must_cover_enum[ + sizeof(tntctl_text_catalog) / sizeof(tntctl_text_catalog[0]) == + TNTCTL_TEXT_COUNT ? 1 : -1]; + +static const char *tntctl_text(ui_lang_t lang, tntctl_text_id_t id) { + if (id < 0 || id >= TNTCTL_TEXT_COUNT) { + return ""; + } + return i18n_string(tntctl_text_catalog[id], lang); +} + +static void print_usage(FILE *stream, ui_lang_t lang) { + fputs(tntctl_text(lang, TNTCTL_TEXT_USAGE), stream); +} + +static void print_error(ui_lang_t lang, tntctl_text_id_t id) { + fprintf(stderr, "tntctl: %s\n", tntctl_text(lang, id)); +} + +static void print_error_format(ui_lang_t lang, tntctl_text_id_t id, + const char *value) { + fprintf(stderr, "tntctl: "); + fprintf(stderr, tntctl_text(lang, id), value); + fputc('\n', stderr); } static bool is_valid_port(const char *value) { @@ -153,6 +259,7 @@ int main(int argc, char **argv) { char **ssh_argv = NULL; int ssh_argc = 0; int rc; + ui_lang_t lang = i18n_default_ui_lang(); for (i = 1; i < argc; i++) { if (strcmp(argv[i], "--") == 0) { @@ -160,7 +267,7 @@ int main(int argc, char **argv) { break; } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { - print_usage(stdout); + print_usage(stdout, lang); return TNT_EXIT_OK; } else if (strcmp(argv[i], "-V") == 0 || strcmp(argv[i], "--version") == 0) { @@ -169,7 +276,7 @@ int main(int argc, char **argv) { } else if (strcmp(argv[i], "-p") == 0 || strcmp(argv[i], "--port") == 0) { if (i + 1 >= argc || !is_valid_port(argv[i + 1])) { - fprintf(stderr, "tntctl: invalid port\n"); + print_error(lang, TNTCTL_TEXT_INVALID_PORT); return TNT_EXIT_USAGE; } port = argv[++i]; @@ -177,26 +284,27 @@ int main(int argc, char **argv) { strcmp(argv[i], "--login") == 0) { if (i + 1 >= argc || is_safe_ssh_token(argv[i + 1]) || strchr(argv[i + 1], '@')) { - fprintf(stderr, "tntctl: invalid login\n"); + print_error(lang, TNTCTL_TEXT_INVALID_LOGIN); return TNT_EXIT_USAGE; } login = argv[++i]; } else if (strcmp(argv[i], "--host-key-checking") == 0) { if (i + 1 >= argc || !is_host_key_checking_mode(argv[i + 1])) { - fprintf(stderr, "tntctl: invalid host-key checking mode\n"); + print_error(lang, TNTCTL_TEXT_INVALID_HOST_KEY_MODE); return TNT_EXIT_USAGE; } host_key_checking = argv[++i]; } else if (strcmp(argv[i], "--known-hosts") == 0) { if (i + 1 >= argc || argv[i + 1][0] == '\0' || has_newline(argv[i + 1])) { - fprintf(stderr, "tntctl: invalid known_hosts path\n"); + print_error(lang, TNTCTL_TEXT_INVALID_KNOWN_HOSTS); return TNT_EXIT_USAGE; } known_hosts = argv[++i]; } else if (argv[i][0] == '-') { - fprintf(stderr, "tntctl: unknown option: %s\n", argv[i]); - print_usage(stderr); + print_error_format(lang, TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT, + argv[i]); + print_usage(stderr, lang); return TNT_EXIT_USAGE; } else { break; @@ -204,29 +312,29 @@ int main(int argc, char **argv) { } if (i >= argc) { - fprintf(stderr, "tntctl: missing host\n"); - print_usage(stderr); + print_error(lang, TNTCTL_TEXT_MISSING_HOST); + print_usage(stderr, lang); return TNT_EXIT_USAGE; } host = argv[i++]; if (is_safe_ssh_token(host)) { - fprintf(stderr, "tntctl: invalid host\n"); + print_error(lang, TNTCTL_TEXT_INVALID_HOST); return TNT_EXIT_USAGE; } if (login && strchr(host, '@')) { - fprintf(stderr, "tntctl: use either --login or user@host, not both\n"); + print_error(lang, TNTCTL_TEXT_LOGIN_HOST_CONFLICT); return TNT_EXIT_USAGE; } if (i >= argc || !is_known_exec_command(argv[i])) { - fprintf(stderr, "tntctl: unknown or missing command\n"); + print_error(lang, TNTCTL_TEXT_UNKNOWN_COMMAND); return TNT_EXIT_USAGE; } if (build_remote_command(remote_command, sizeof(remote_command), argc, argv, i) < 0) { - fprintf(stderr, "tntctl: invalid or too-long command\n"); + print_error(lang, TNTCTL_TEXT_INVALID_REMOTE_COMMAND); return TNT_EXIT_USAGE; } @@ -234,24 +342,24 @@ int main(int argc, char **argv) { int n = snprintf(destination, sizeof(destination), "%s@%s", login, host); if (n < 0 || n >= (int)sizeof(destination)) { - fprintf(stderr, "tntctl: destination too long\n"); + print_error(lang, TNTCTL_TEXT_DESTINATION_TOO_LONG); return TNT_EXIT_USAGE; } } else { int n = snprintf(destination, sizeof(destination), "%s", host); if (n < 0 || n >= (int)sizeof(destination)) { - fprintf(stderr, "tntctl: destination too long\n"); + print_error(lang, TNTCTL_TEXT_DESTINATION_TOO_LONG); return TNT_EXIT_USAGE; } } if (destination[0] == '-') { - fprintf(stderr, "tntctl: invalid destination\n"); + print_error(lang, TNTCTL_TEXT_INVALID_DESTINATION); return TNT_EXIT_USAGE; } ssh_argv = calloc((size_t)argc * 2u + 8u, sizeof(*ssh_argv)); if (!ssh_argv) { - fprintf(stderr, "tntctl: out of memory\n"); + print_error(lang, TNTCTL_TEXT_OUT_OF_MEMORY); return TNT_EXIT_ERROR; } @@ -262,7 +370,7 @@ int main(int argc, char **argv) { int n = snprintf(host_key_option, sizeof(host_key_option), "StrictHostKeyChecking=%s", host_key_checking); if (n < 0 || n >= (int)sizeof(host_key_option)) { - fprintf(stderr, "tntctl: host-key option too long\n"); + print_error(lang, TNTCTL_TEXT_HOST_KEY_OPTION_TOO_LONG); free(ssh_argv); return TNT_EXIT_USAGE; } @@ -273,7 +381,7 @@ int main(int argc, char **argv) { int n = snprintf(known_hosts_option, sizeof(known_hosts_option), "UserKnownHostsFile=%s", known_hosts); if (n < 0 || n >= (int)sizeof(known_hosts_option)) { - fprintf(stderr, "tntctl: known_hosts option too long\n"); + print_error(lang, TNTCTL_TEXT_KNOWN_HOSTS_OPTION_TOO_LONG); free(ssh_argv); return TNT_EXIT_USAGE; } diff --git a/tests/test_tntctl_cli.sh b/tests/test_tntctl_cli.sh index face0e0..2acf5e9 100755 --- a/tests/test_tntctl_cli.sh +++ b/tests/test_tntctl_cli.sh @@ -73,6 +73,33 @@ case "$VERSION_OUTPUT" in *) echo "✗ version output unexpected: $VERSION_OUTPUT"; FAIL=$((FAIL + 1)) ;; esac +HELP_ZH=$(TNT_LANG=zh "$BIN" --help 2>/dev/null || true) +printf '%s\n' "$HELP_ZH" | grep -q '^用法: tntctl \[options\] host command \[args...\]' && +printf '%s\n' "$HELP_ZH" | grep -q '^选项:$' +if [ $? -eq 0 ]; then + echo "✓ local help follows TNT_LANG" + PASS=$((PASS + 1)) +else + echo "✗ localized help output unexpected" + printf '%s\n' "$HELP_ZH" + FAIL=$((FAIL + 1)) +fi + +rm -f "$SSH_LOG" +BAD_PORT_ZH=$(PATH="$FAKE_BIN:$PATH" TNTCTL_SSH_LOG="$SSH_LOG" TNT_LANG=zh "$BIN" -p nope example.com health 2>&1) +BAD_PORT_STATUS=$? +if [ "$BAD_PORT_STATUS" -eq 64 ] && + [ ! -f "$SSH_LOG" ] && + printf '%s\n' "$BAD_PORT_ZH" | grep -q '^tntctl: 端口无效$'; then + echo "✓ local diagnostics follow TNT_LANG" + PASS=$((PASS + 1)) +else + echo "✗ localized diagnostic unexpected" + printf '%s\n' "$BAD_PORT_ZH" + [ -f "$SSH_LOG" ] && echo "fake ssh was invoked" + FAIL=$((FAIL + 1)) +fi + run_ok "basic argv shape" "$BIN" -p 2222 example.com health grep -q '^example.com$' "$SSH_LOG" && grep -q '^health$' "$SSH_LOG"