@dr-co/dsh-node-client 0.0.0-stage → 0.0.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/i18n.mjs ADDED
@@ -0,0 +1,511 @@
1
+ /**
2
+ * Язык сообщений клиента: китайский, английский (по умолчанию) и русский.
3
+ *
4
+ * Источник истины — локаль: `DSHNC_LANG`, затем `LC_ALL`, затем `LANG`. Язык,
5
+ * которого нет в словаре, не ошибка: падаем на английский. Так же и с
6
+ * отсутствующим ключом — берём английский, чтобы сообщение никогда не было
7
+ * пустым.
8
+ *
9
+ * @module dsh-node-client/i18n
10
+ */
11
+
12
+ export const LANGS = ['en', 'ru', 'zh']
13
+
14
+ const DICT = {
15
+ en: {
16
+ 'files.readChunk.description': 'Read a binary chunk: base64, for copying big files',
17
+ 'files.writeChunk.description': 'Write a chunk into <path>.inprogress; the last chunk renames it into place',
18
+ 'files.sha256.description': 'Hash of a file: compare after copying',
19
+ 'files.arg.base64': 'Chunk bytes in base64',
20
+ 'files.arg.final': 'Last chunk: rename .inprogress into the target path',
21
+ 'help.config': 'config [key=value…] client settings: show or change',
22
+ 'help.policy': 'policy [url] [key=…] per-server policy: max, base, approver, tool',
23
+ 'help.audit': 'audit [n] commands that ran without the sandbox',
24
+ 'help.workdir': 'workdir [path] session working directory: show or set',
25
+ 'help.onboard': 'onboard setup wizard: menu, current values as defaults',
26
+ 'files.api.description': 'Node files: read and write without a shell',
27
+ 'files.read.description': 'Read a file: text and version for later edits',
28
+ 'files.write.description': 'Write a file atomically; ifVersion protects against clobbering',
29
+ 'files.edit.description': 'Replace a literal string in a file; refuses ambiguity',
30
+ 'files.list.description': 'List one directory level',
31
+ 'files.stat.description': 'Check existence, size and version',
32
+ 'files.arg.offset': 'Byte offset to start reading from',
33
+ 'files.arg.limit': 'How many bytes to read at most',
34
+ 'files.arg.version': 'Version from read/stat: refuse the write if the file changed',
35
+ 'files.arg.dirs': 'Create parent directories (default true)',
36
+ 'daemon.reload': 'reloading config (SIGHUP)',
37
+ 'presence.pongTimeout': 'no answer to the ping within {seconds}s — reconnecting',
38
+ 'config.saved': 'settings saved: {patch}',
39
+ 'config.usage': 'usage: dshnc config key=value [key=value…]',
40
+ 'session.ping.description': 'Check that the node answers; returns pong, uptime and platform',
41
+ 'run.resultFailed': 'seq {seq}: could not deliver the result — {message}',
42
+ 'audit.empty': 'no commands without the sandbox yet',
43
+ 'run.auditFailed': 'audit write failed: {message}',
44
+ 'session.chdir.description': 'Switch the working directory of this session (it must exist; create it with session.mkdir)',
45
+ 'session.chdir.arg.path': 'Absolute path of an existing directory (inside the node roots)',
46
+ 'session.mkdir.description': 'Create a directory inside the node roots',
47
+ 'session.mkdir.arg.path': 'Absolute path to create',
48
+ 'session.mkdir.arg.mode': 'Permissions in octal, for example 755',
49
+ 'session.exists': ' (already exists)',
50
+ 'err.writeState': 'cannot write client state: {message} (inside the sandbox only the workdir is writable)',
51
+ 'policy.current': 'access: {access} | roots: {roots}',
52
+ 'policy.saved': 'policy for {url}: {patch}',
53
+ 'session.api.description': 'Session itself: switch the working directory of the session',
54
+ 'session.chdir.description': 'Switch the working directory of this session (all later commands start there)',
55
+ 'session.chdir.arg.path': 'Absolute directory path (must be inside the node roots)',
56
+ 'session.chdir.arg.create': 'Create the directory if it does not exist',
57
+ 'session.cwd.description': 'Show the current working directory of the session',
58
+ 'session.arg.session': 'Session id (defaults to the calling session)',
59
+ 'session.noStore': 'session: the client has no session store',
60
+ 'session.noSession': 'session: no session id in the command metadata',
61
+ 'session.created': ' (created)',
62
+ 'workdir.set': 'workdir: {dir} — each session gets its own subdirectory',
63
+ 'workdir.none': 'workdir is not set: commands run in the daemon directory',
64
+ 'auto.noLauncher': 'WARNING: {path} does not exist — the unit will fail to start',
65
+ 'auto.copyState': 'Move the current state (token, cursor) to {dir}? [y/N]',
66
+ 'auto.copied': 'State copied: {dir}',
67
+ 'auto.copyFailed': 'Could not copy the state ({message}) — run `dshnc connect <link>` as {user}',
68
+ 'auto.copySkip': 'Leaving the state here; the new user will need `dshnc connect <link>`',
69
+ 'help.autostart': 'autostart questions and a systemd unit',
70
+ 'unit.description': 'dsh-nodes client (dshnc) — remote commands executor',
71
+ 'auto.title': 'Setting up autostart (systemd)',
72
+ 'auto.name': 'Node name',
73
+ 'auto.aliases': 'Aliases, comma separated (the node can be called by any)',
74
+ 'auto.scope': 'Run as system service [system] or inside your user session [user]',
75
+ 'auto.dedicated': 'Create a dedicated system user for it? [y/N]',
76
+ 'auto.username': 'User name',
77
+ 'auto.createUser': 'No rights to create users. Run this and press Enter when done:',
78
+ 'auto.waitingUser': 'waiting for user {user}…',
79
+ 'auto.graphical': 'Start only inside the graphical session (needs DISPLAY)? [y/N]',
80
+ 'auto.written': 'Unit written: {path}',
81
+ 'auto.next': 'Now, as a user with rights:',
82
+ 'auto.stateDir': 'State (token, cursor) lives in {dir}',
83
+ 'auto.copyConfig': 'Copying the existing state to {dir}',
84
+ 'auto.noToken': 'No token yet: after install run `sudo -u {user} dshnc connect <link>`',
85
+ 'auto.done': 'Done.',
86
+ 'bash.arg.stdin': 'Data passed to the command on stdin (up to 64 KiB)',
87
+ 'bash.api.description': 'Shell on the node machine',
88
+ 'bash.exec.description': 'Run a command in the shell and return stdout, stderr and exit code',
89
+ 'bash.arg.command': 'Command line',
90
+ 'bash.arg.cwd': 'Working directory',
91
+ 'bash.arg.timeout': 'Timeout in seconds',
92
+ 'help.title': 'dshnc — dsh-nodes client ({home})',
93
+ 'help.connect': 'connect <url> connect: a link with a code does everything,',
94
+ 'help.connect2': ' without a code — request and wait for approval',
95
+ 'help.daemon': 'daemon [url...] work: declare bash, take commands, reply;',
96
+ 'help.daemon2': ' without arguments — every server from the config;',
97
+ 'help.daemon3': ' runs in the foreground (background via systemd/nohup)',
98
+ 'help.foreground': '--foreground kept for compatibility, same behaviour',
99
+ 'help.status': 'status client id and saved servers',
100
+ 'help.version': 'version client version',
101
+ 'help.help': 'help this screen',
102
+ 'help.env': 'Environment: DSHNC_HOME (state directory), DSHNC_SHELL (default /bin/sh), DSHNC_LANG (en, ru, zh)',
103
+ 'status.id': 'client id: {id}',
104
+ 'status.name': 'name: {name}',
105
+ 'status.none': 'no servers yet — start with `dshnc connect <url>`',
106
+ 'status.server': '{url}: {token}, cursor {cursor}, epoch {epoch}{alias}',
107
+ 'status.token.yes': 'token present',
108
+ 'status.token.no': 'no token',
109
+ 'status.alias': ', node {alias}',
110
+ 'version.line': 'dshnc {version} (protocol {proto})',
111
+ 'cmd.unknown': 'unknown command: {command}',
112
+ 'err.needUrl': 'a url is required: dshnc connect https://host/dsh-nodes/pair?v=...',
113
+ 'err.nothingToServe': 'nothing to serve: run `dshnc connect <url>` first',
114
+ 'err.generic': 'error: {message}',
115
+ 'err.aborted': 'aborted',
116
+ 'connect.paired': 'connected: node {node} ({alias}), token saved',
117
+ 'connect.requested': 'request sent: {name} / {short}',
118
+ 'connect.waiting': 'the admin has to approve it in the admin panel — waiting',
119
+ 'connect.approved': 'approved: node {node} ({alias}), token saved',
120
+ 'connect.rejected': 'the request was rejected',
121
+ 'connect.handed': 'the token was already handed out; reissue it in the admin',
122
+ 'connect.timeout': 'the request was not approved within 30 minutes',
123
+ 'connect.done': 'done. next: dshnc daemon',
124
+ 'connect.noCode': 'the link carries no code — connection impossible',
125
+ 'daemon.servers': 'servers: {servers}',
126
+ 'daemon.plugins': 'plugins: {plugins}',
127
+ 'daemon.stopping': 'stopping',
128
+ 'auto.nodeSummary': 'node: {name}',
129
+ 'auto.aliasesSuffix': ', aliases: {aliases}',
130
+ 'auto.userCreated': 'created user {user}',
131
+ 'bash.clipped': '\n… truncated: showing {shown} of {total} characters',
132
+ 'presence.sendFailed': 'could not send a frame: {message}',
133
+ 'presence.openFailed': 'WebSocket did not open: {message}',
134
+ 'presence.badFrame': 'cannot parse a frame',
135
+ 'run.noPlugin': 'no plugin for method {method}',
136
+ 'run.announced': '{base}: declared {apis}, epoch {epoch}',
137
+ 'run.warning': 'warning: {warning}',
138
+ 'run.reconnect': '{base}: the server is of another life (epoch {from} → {to}), cursor reset',
139
+ 'run.restarted': '{base}: the server restarted (epoch {from} → {to}), cursor reset',
140
+ 'run.presenceClosed': '{base}: presence closed ({code}), falling back to polling',
141
+ 'run.retry': '{base}: {message} — retrying in {seconds}s',
142
+ 'run.tokenRejected': '{base}: the token does not fit, a new request is needed',
143
+ 'run.command': '{base}: {seq} {method} → {flag}',
144
+ 'run.ok': 'ok',
145
+ 'run.error': 'error {code}',
146
+ 'api.unauthorized': 'the token did not fit',
147
+ 'api.timeout': 'the server did not answer',
148
+ 'api.http': 'HTTP {status}',
149
+ 'bash.needCommand': 'command is required and must be a non-empty string',
150
+ 'bash.timeout': 'the command did not finish within {seconds}s and was killed',
151
+ 'bash.cancelled': 'the call was cancelled',
152
+ 'plugins.noName': '{label}: no api.name',
153
+ 'plugins.nameType': '{label}: api.name must be a string',
154
+ 'plugins.noMethods': '{label}: api.methods is empty',
155
+ 'plugins.noHandle': '{label}: no handle()',
156
+ 'plugins.methodNoName': '{label}: a method without a name',
157
+ 'plugins.methodNoArgs': '{label}: method {method} has no args schema',
158
+ 'plugins.directory': 'cannot read the plugins directory: {message}',
159
+ 'plugins.taken': 'plugin {entry} declares api "{name}", which is already taken — skipped',
160
+ 'plugins.failed': 'plugin {entry} did not load: {message}',
161
+ },
162
+ ru: {
163
+ 'files.readChunk.description': 'Прочитать двоичный кусок: base64, для копирования больших файлов',
164
+ 'files.writeChunk.description': 'Записать кусок в <path>.inprogress; последний переименовывает файл на место',
165
+ 'files.sha256.description': 'Хеш файла: сверять после копирования',
166
+ 'files.arg.base64': 'Байты куска в base64',
167
+ 'files.arg.final': 'Последний кусок: переименовать .inprogress в целевой путь',
168
+ 'help.config': 'config [ключ=значение…] настройки клиента: показать или изменить',
169
+ 'help.policy': 'policy [url] [ключ=…] правила для сервера: max, base, approver, tool',
170
+ 'help.audit': 'audit [сколько] что исполнялось без песочницы (последние записи)',
171
+ 'help.workdir': 'workdir [путь] рабочая директория: показать или задать',
172
+ 'help.onboard': 'onboard мастер настройки: меню, текущие значения по умолчанию',
173
+ 'files.api.description': 'Файлы на узле: чтение и запись без оболочки',
174
+ 'files.read.description': 'Прочитать файл: текст и версия для последующих правок',
175
+ 'files.write.description': 'Записать файл атомарно; ifVersion защищает от затирания',
176
+ 'files.edit.description': 'Заменить строку в файле; при неоднозначности отказывает',
177
+ 'files.list.description': 'Показать содержимое каталога',
178
+ 'files.stat.description': 'Проверить наличие, размер и версию',
179
+ 'files.arg.offset': 'С какого байта читать',
180
+ 'files.arg.limit': 'Сколько байт прочитать максимум',
181
+ 'files.arg.version': 'Версия из read/stat: отказ, если файл изменился',
182
+ 'files.arg.dirs': 'Создавать родительские каталоги (по умолчанию да)',
183
+ 'daemon.reload': 'перечитываю конфиг (SIGHUP)',
184
+ 'presence.pongTimeout': 'нет ответа на пинг за {seconds} с — переподключаюсь',
185
+ 'config.saved': 'настройки сохранены: {patch}',
186
+ 'config.usage': 'использование: dshnc config ключ=значение [ключ=значение…]',
187
+ 'session.ping.description': 'Проверить, что узел отвечает: возвращает pong, аптайм и платформу',
188
+ 'run.resultFailed': 'seq {seq}: не смог отправить результат — {message}',
189
+ 'audit.empty': 'команд без песочницы пока не было',
190
+ 'run.auditFailed': 'не смог записать аудит: {message}',
191
+ 'session.chdir.description': 'Переключить рабочий каталог сессии (каталог должен существовать; создать — session.mkdir)',
192
+ 'session.chdir.arg.path': 'Абсолютный путь существующего каталога (внутри разрешённых на узле)',
193
+ 'session.mkdir.description': 'Создать каталог внутри разрешённых на узле',
194
+ 'session.mkdir.arg.path': 'Абсолютный путь, который нужно создать',
195
+ 'session.mkdir.arg.mode': 'Права в восьмеричном виде, например 755',
196
+ 'session.exists': ' (уже существует)',
197
+ 'err.writeState': 'не удалось записать состояние клиента: {message} (внутри песочницы писать можно только в рабочий каталог)',
198
+ 'policy.current': 'доступ: {access} | разрешённые каталоги: {roots}',
199
+ 'policy.saved': 'правила для {url}: {patch}',
200
+ 'session.api.description': 'Сама сессия: переключение рабочего каталога',
201
+ 'session.chdir.description': 'Переключить рабочий каталог сессии — все следующие команды начнутся там',
202
+ 'session.chdir.arg.path': 'Абсолютный путь каталога (должен быть внутри разрешённых на узле)',
203
+ 'session.chdir.arg.create': 'Создать каталог, если его нет',
204
+ 'session.cwd.description': 'Показать текущий рабочий каталог сессии',
205
+ 'session.arg.session': 'Идентификатор сессии (по умолчанию — вызывающая)',
206
+ 'session.noStore': 'session: у клиента нет хранилища сессий',
207
+ 'session.noSession': 'session: в метаданных команды нет идентификатора сессии',
208
+ 'session.created': ' (создан)',
209
+ 'workdir.set': 'рабочая директория: {dir} — у каждой сессии будет свой подкаталог',
210
+ 'workdir.none': 'рабочая директория не задана: команды идут в каталоге демона',
211
+ 'auto.noLauncher': 'ВНИМАНИЕ: {path} не существует — юнит не запустится',
212
+ 'auto.copyState': 'Перенести текущее состояние (токен, курсор) в {dir}? [y/N]',
213
+ 'auto.copied': 'Состояние перенесено: {dir}',
214
+ 'auto.copyFailed': 'Не удалось перенести состояние ({message}) — выполни `dshnc connect <ссылка>` от {user}',
215
+ 'auto.copySkip': 'Состояние оставляю здесь; новому пользователю понадобится `dshnc connect <ссылка>`',
216
+ 'help.autostart': 'autostart вопросы и автозапуск через systemd',
217
+ 'unit.description': 'клиент dsh-nodes (dshnc) — исполнитель удалённых команд',
218
+ 'auto.title': 'Настройка автозапуска (systemd)',
219
+ 'auto.name': 'Имя узла',
220
+ 'auto.aliases': 'Алиасы через запятую (по любому из них узел можно позвать)',
221
+ 'auto.scope': 'Запускать системной службой [system] или внутри твоей сессии [user]',
222
+ 'auto.dedicated': 'Создать для него отдельного системного пользователя? [y/N]',
223
+ 'auto.username': 'Имя пользователя',
224
+ 'auto.createUser': 'Нет прав создавать пользователей. Выполни это и нажми Enter:',
225
+ 'auto.waitingUser': 'жду пользователя {user}…',
226
+ 'auto.graphical': 'Запускать только внутри графической сессии (нужен DISPLAY)? [y/N]',
227
+ 'auto.written': 'Юнит записан: {path}',
228
+ 'auto.next': 'Теперь от имени пользователя с правами:',
229
+ 'auto.stateDir': 'Состояние (токен, курсор) живёт в {dir}',
230
+ 'auto.copyConfig': 'Копирую существующее состояние в {dir}',
231
+ 'auto.noToken': 'Токена ещё нет: после установки выполни `sudo -u {user} dshnc connect <ссылка>`',
232
+ 'auto.done': 'Готово.',
233
+ 'bash.arg.stdin': 'Данные в stdin команды (до 64 КиБ)',
234
+ 'bash.api.description': 'Shell на машине узла',
235
+ 'bash.exec.description': 'Выполнить команду в shell и вернуть stdout, stderr и код возврата',
236
+ 'bash.arg.command': 'Командная строка',
237
+ 'bash.arg.cwd': 'Рабочий каталог',
238
+ 'bash.arg.timeout': 'Таймаут в секундах',
239
+ 'help.title': 'dshnc — клиент dsh-nodes ({home})',
240
+ 'help.connect': 'connect <url> подключиться: ссылка с кодом связывает всё сама,',
241
+ 'help.connect2': ' без кода — заявка и ожидание одобрения в админке',
242
+ 'help.daemon': 'daemon [url...] работать: объявить bash, забирать команды, отвечать',
243
+ 'help.daemon2': ' без аргументов — все серверы из конфига;',
244
+ 'help.daemon3': ' работает на переднем плане (фон — через systemd/nohup)',
245
+ 'help.foreground': '--foreground оставлено для совместимости, поведение то же',
246
+ 'help.status': 'status показать id клиента и сохранённые серверы',
247
+ 'help.version': 'version версия клиента',
248
+ 'help.help': 'help этот экран',
249
+ 'help.env': 'Переменные: DSHNC_HOME (каталог состояния), DSHNC_SHELL (шелл, по умолчанию /bin/sh), DSHNC_LANG (en, ru, zh)',
250
+ 'status.id': 'id клиента: {id}',
251
+ 'status.name': 'имя: {name}',
252
+ 'status.none': 'серверов нет — начни с `dshnc connect <url>`',
253
+ 'status.server': '{url}: {token}, курсор {cursor}, epoch {epoch}{alias}',
254
+ 'status.token.yes': 'токен есть',
255
+ 'status.token.no': 'токена нет',
256
+ 'status.alias': ', узел {alias}',
257
+ 'version.line': 'dshnc {version} (протокол {proto})',
258
+ 'cmd.unknown': 'неизвестная команда: {command}',
259
+ 'err.needUrl': 'нужен url: dshnc connect https://host/dsh-nodes/pair?v=...',
260
+ 'err.nothingToServe': 'нечего обслуживать: сначала `dshnc connect <url>`',
261
+ 'err.generic': 'ошибка: {message}',
262
+ 'err.aborted': 'прервано',
263
+ 'connect.paired': 'подключено: узел {node} ({alias}), токен сохранён',
264
+ 'connect.requested': 'заявка отправлена: {name} / {short}',
265
+ 'connect.waiting': 'админ должен допустить её в админке — ждём',
266
+ 'connect.approved': 'допущен: узел {node} ({alias}), токен сохранён',
267
+ 'connect.rejected': 'заявку отклонили',
268
+ 'connect.handed': 'токен уже выдан; перевыпусти в админке',
269
+ 'connect.timeout': 'заявка не одобрена за 30 минут',
270
+ 'connect.done': 'готово. дальше: dshnc daemon',
271
+ 'connect.noCode': 'в ссылке нет кода — подключение не состоится',
272
+ 'daemon.servers': 'серверы: {servers}',
273
+ 'daemon.plugins': 'плагины: {plugins}',
274
+ 'daemon.stopping': 'останавливаюсь',
275
+ 'auto.nodeSummary': 'узел: {name}',
276
+ 'auto.aliasesSuffix': ', алиасы: {aliases}',
277
+ 'auto.userCreated': 'создан пользователь {user}',
278
+ 'bash.clipped': '\n… обрезано: показано {shown} из {total} символов',
279
+ 'presence.sendFailed': 'не удалось отправить кадр: {message}',
280
+ 'presence.openFailed': 'WebSocket не открылся: {message}',
281
+ 'presence.badFrame': 'кадр не разбирается',
282
+ 'run.noPlugin': 'нет плагина для метода {method}',
283
+ 'run.announced': '{base}: объявили {apis}, epoch {epoch}',
284
+ 'run.warning': 'предупреждение: {warning}',
285
+ 'run.reconnect': '{base}: сервер другой жизни (epoch {from} → {to}), курсор сброшен',
286
+ 'run.restarted': '{base}: сервер перезапущен (epoch {from} → {to}), курсор сброшен',
287
+ 'run.presenceClosed': '{base}: присутствие закрыто ({code}), работаю опросом',
288
+ 'run.retry': '{base}: {message} — повтор через {seconds} с',
289
+ 'run.tokenRejected': '{base}: токен не подошёл, нужна новая заявка',
290
+ 'run.command': '{base}: {seq} {method} → {flag}',
291
+ 'run.ok': 'ok',
292
+ 'run.error': 'ошибка {code}',
293
+ 'api.unauthorized': 'токен не подошёл',
294
+ 'api.timeout': 'сервер не ответил',
295
+ 'api.http': 'HTTP {status}',
296
+ 'bash.needCommand': 'command обязателен и должен быть непустой строкой',
297
+ 'bash.timeout': 'команда не уложилась в {seconds} с и была убита',
298
+ 'bash.cancelled': 'вызов отменён',
299
+ 'plugins.noName': '{label}: нет api.name',
300
+ 'plugins.nameType': '{label}: api.name должен быть строкой',
301
+ 'plugins.noMethods': '{label}: api.methods пуст',
302
+ 'plugins.noHandle': '{label}: нет handle()',
303
+ 'plugins.methodNoName': '{label}: метод без имени',
304
+ 'plugins.methodNoArgs': '{label}: метод {method} без схемы args',
305
+ 'plugins.directory': 'каталог плагинов не читается: {message}',
306
+ 'plugins.taken': 'плагин {entry} объявляет api «{name}», который уже занят — пропущен',
307
+ 'plugins.failed': 'плагин {entry} не загрузился: {message}',
308
+ },
309
+ zh: {
310
+ 'files.readChunk.description': '读取二进制块:base64',
311
+ 'files.writeChunk.description': '写入数据块',
312
+ 'files.sha256.description': '文件哈希',
313
+ 'files.arg.base64': 'base64 字节',
314
+ 'files.arg.final': '最后一块',
315
+ 'help.config': 'config [键=值…] 客户端设置:查看或修改',
316
+ 'help.policy': 'policy [url] [键=…] 每个服务器的策略:max、base、approver、tool',
317
+ 'help.audit': 'audit [数量] 未经沙箱执行的命令',
318
+ 'help.workdir': 'workdir [路径] 会话工作目录:查看或设置',
319
+ 'help.onboard': 'onboard 配置向导:菜单,当前值作为默认值',
320
+ 'files.api.description': '节点文件:无 shell 的读写',
321
+ 'files.read.description': '读取文件:文本和版本',
322
+ 'files.write.description': '原子写入文件',
323
+ 'files.edit.description': '在文件中替换字符串',
324
+ 'files.list.description': '列出一个目录',
325
+ 'files.stat.description': '检查是否存在与版本',
326
+ 'files.arg.offset': '起始字节偏移',
327
+ 'files.arg.limit': '最大读取字节数',
328
+ 'files.arg.version': '来自 read/stat 的版本',
329
+ 'files.arg.dirs': '创建父目录',
330
+ 'daemon.reload': '正在重新读取配置(SIGHUP)',
331
+ 'presence.pongTimeout': '{seconds} 秒内没有回应 ping——正在重连',
332
+ 'config.saved': '设置已保存:{patch}',
333
+ 'config.usage': '用法:dshnc config 键=值 [键=值…]',
334
+ 'session.ping.description': '检查节点是否有响应:返回 pong、运行时长和平台',
335
+ 'run.resultFailed': 'seq {seq}:无法提交结果——{message}',
336
+ 'audit.empty': '还没有无沙箱命令',
337
+ 'run.auditFailed': '无法写入审计:{message}',
338
+ 'session.chdir.description': '切换会话工作目录(目录必须存在;创建请用 session.mkdir)',
339
+ 'session.chdir.arg.path': '已存在目录的绝对路径(在节点允许范围内)',
340
+ 'session.mkdir.description': '在节点允许范围内创建目录',
341
+ 'session.mkdir.arg.path': '要创建的绝对路径',
342
+ 'session.mkdir.arg.mode': '八进制权限,例如 755',
343
+ 'session.exists': '(已存在)',
344
+ 'err.writeState': '无法写入客户端状态:{message}(沙箱内只能写工作目录)',
345
+ 'policy.current': '访问:{access} | 允许目录:{roots}',
346
+ 'policy.saved': '{url} 的规则:{patch}',
347
+ 'session.api.description': '会话本身:切换会话的工作目录',
348
+ 'session.chdir.description': '切换本会话的工作目录(后续命令都在其中启动)',
349
+ 'session.chdir.arg.path': '绝对目录路径(必须在节点允许的范围内)',
350
+ 'session.chdir.arg.create': '目录不存在时创建',
351
+ 'session.cwd.description': '显示会话当前的工作目录',
352
+ 'session.arg.session': '会话标识(默认是调用方会话)',
353
+ 'session.noStore': 'session:客户端没有会话存储',
354
+ 'session.noSession': 'session:命令元数据里没有会话标识',
355
+ 'session.created': '(已创建)',
356
+ 'workdir.set': '工作目录:{dir}——每个会话有独立子目录',
357
+ 'workdir.none': '未设置工作目录:命令在守护进程目录中运行',
358
+ 'auto.noLauncher': '注意:{path} 不存在——单元将无法启动',
359
+ 'auto.copyState': '把当前状态(令牌、游标)搬到 {dir} 吗?[y/N]',
360
+ 'auto.copied': '状态已复制:{dir}',
361
+ 'auto.copyFailed': '无法复制状态({message})——请以 {user} 身份执行 `dshnc connect <链接>`',
362
+ 'auto.copySkip': '状态留在原处;新用户需要执行 `dshnc connect <链接>`',
363
+ 'help.autostart': 'autostart 几个问题,然后配置 systemd 自启',
364
+ 'unit.description': 'dsh-nodes 客户端(dshnc)——远程命令执行器',
365
+ 'auto.title': '配置开机自启(systemd)',
366
+ 'auto.name': '节点名称',
367
+ 'auto.aliases': '别名,用逗号分隔(任意一个都能叫到节点)',
368
+ 'auto.scope': '作为系统服务运行 [system] 还是在你自己的会话里 [user]',
369
+ 'auto.dedicated': '为它创建独立的系统用户?[y/N]',
370
+ 'auto.username': '用户名',
371
+ 'auto.createUser': '没有创建用户的权限。请执行下面的命令,完成后按 Enter:',
372
+ 'auto.waitingUser': '正在等待用户 {user}…',
373
+ 'auto.graphical': '仅在图形会话中启动(需要 DISPLAY)?[y/N]',
374
+ 'auto.written': '单元文件已写入:{path}',
375
+ 'auto.next': '现在用有权限的用户执行:',
376
+ 'auto.stateDir': '状态(令牌、游标)位于 {dir}',
377
+ 'auto.copyConfig': '正在把现有状态复制到 {dir}',
378
+ 'auto.noToken': '还没有令牌:安装后执行 `sudo -u {user} dshnc connect <链接>`',
379
+ 'auto.done': '完成。',
380
+ 'bash.arg.stdin': '传给命令 stdin 的数据(最多 64 KiB)',
381
+ 'bash.api.description': '节点机器上的 shell',
382
+ 'bash.exec.description': '在 shell 中执行命令,返回 stdout、stderr 和退出码',
383
+ 'bash.arg.command': '命令行',
384
+ 'bash.arg.cwd': '工作目录',
385
+ 'bash.arg.timeout': '超时时间(秒)',
386
+ 'help.title': 'dshnc — 客户端 dsh-nodes ({home})',
387
+ 'help.connect': 'connect <url> 连接:带验证码的链接会自己完成绑定,',
388
+ 'help.connect2': ' 没有验证码时——提交申请并在管理页等待批准',
389
+ 'help.daemon': 'daemon [url...] 运行:声明 bash,取命令,回结果',
390
+ 'help.daemon2': ' 不带参数时——配置文件里的所有服务器;',
391
+ 'help.daemon3': ' 前台运行(后台交给 systemd/nohup)',
392
+ 'help.foreground': '--foreground 保留兼容,行为相同',
393
+ 'help.status': 'status 显示客户端 id 和已保存的服务器',
394
+ 'help.version': 'version 客户端版本',
395
+ 'help.help': 'help 这个界面',
396
+ 'help.env': '环境变量:DSHNC_HOME(状态目录)、DSHNC_SHELL(默认 /bin/sh)、DSHNC_LANG(en、ru、zh)',
397
+ 'status.id': '客户端 id:{id}',
398
+ 'status.name': '名称: {name}',
399
+ 'status.none': '还没有服务器——先运行 `dshnc connect <url>`',
400
+ 'status.server': '{url}:{token},游标 {cursor},epoch {epoch}{alias}',
401
+ 'status.token.yes': '有令牌',
402
+ 'status.token.no': '没有令牌',
403
+ 'status.alias': ',节点 {alias}',
404
+ 'version.line': 'dshnc {version}(协议 {proto})',
405
+ 'cmd.unknown': '未知命令:{command}',
406
+ 'err.needUrl': '需要 url:dshnc connect https://host/dsh-nodes/pair?v=...',
407
+ 'err.nothingToServe': '没有可服务的服务器:先运行 `dshnc connect <url>`',
408
+ 'err.generic': '错误:{message}',
409
+ 'err.aborted': '已中断',
410
+ 'connect.paired': '已连接:节点 {node}({alias}),令牌已保存',
411
+ 'connect.requested': '申请已发送:{name} / {short}',
412
+ 'connect.waiting': '管理员需在管理面板中批准——等待中',
413
+ 'connect.approved': '已批准:节点 {node}({alias}),令牌已保存',
414
+ 'connect.rejected': '申请被拒绝',
415
+ 'connect.handed': '令牌已发出;请在管理页重新签发',
416
+ 'connect.timeout': '申请在 30 分钟内未获批准',
417
+ 'connect.done': '完成。下一步:dshnc daemon',
418
+ 'connect.noCode': '链接里没有验证码——无法连接',
419
+ 'daemon.servers': '服务器:{servers}',
420
+ 'daemon.plugins': '插件:{plugins}',
421
+ 'daemon.stopping': '正在停止',
422
+ 'auto.nodeSummary': '节点:{name}',
423
+ 'auto.aliasesSuffix': ',别名:{aliases}',
424
+ 'auto.userCreated': '已创建用户 {user}',
425
+ 'bash.clipped': '\n… 已截断:显示 {total} 个字符中的 {shown} 个',
426
+ 'presence.sendFailed': '无法发送帧:{message}',
427
+ 'presence.openFailed': 'WebSocket 未能打开:{message}',
428
+ 'presence.badFrame': '无法解析帧',
429
+ 'run.noPlugin': '方法 {method} 没有插件',
430
+ 'run.announced': '{base}:已声明 {apis},epoch {epoch}',
431
+ 'run.warning': '警告:{warning}',
432
+ 'run.reconnect': '{base}:服务器已换代(epoch {from} → {to}),游标已重置',
433
+ 'run.restarted': '{base}:服务器重启(epoch {from} → {to}),游标已重置',
434
+ 'run.presenceClosed': '{base}:连接已关闭({code}),改用轮询',
435
+ 'run.retry': '{base}:{message}——{seconds} 秒后重试',
436
+ 'run.tokenRejected': '{base}:令牌不匹配,需要重新申请',
437
+ 'run.command': '{base}:{seq} {method} → {flag}',
438
+ 'run.ok': 'ok',
439
+ 'run.error': '错误 {code}',
440
+ 'api.unauthorized': '令牌不匹配',
441
+ 'api.timeout': '服务器没有响应',
442
+ 'api.http': 'HTTP {status}',
443
+ 'bash.needCommand': 'command 必填,且必须是非空字符串',
444
+ 'bash.timeout': '命令未在 {seconds} 秒内完成,已被终止',
445
+ 'bash.cancelled': '调用已取消',
446
+ 'plugins.noName': '{label}: 缺少 api.name',
447
+ 'plugins.nameType': '{label}: api.name 必须是字符串',
448
+ 'plugins.noMethods': '{label}: api.methods 为空',
449
+ 'plugins.noHandle': '{label}: 缺少 handle()',
450
+ 'plugins.methodNoName': '{label}: 有方法没有名字',
451
+ 'plugins.methodNoArgs': '{label}: 方法 {method} 没有 args 结构',
452
+ 'plugins.directory': '无法读取插件目录:{message}',
453
+ 'plugins.taken': '插件 {entry} 声明的 api“{name}”已被占用——跳过',
454
+ 'plugins.failed': '插件 {entry} 加载失败:{message}',
455
+ },
456
+ }
457
+
458
+ /** Привести локаль к одному из поддерживаемых языков. */
459
+ export function normalizeLang (value) {
460
+ if (!value) return null
461
+ const tag = String(value).trim().toLowerCase().replace('_', '-')
462
+ if (!tag) return null
463
+ if (tag.startsWith('zh')) return 'zh'
464
+ if (tag.startsWith('ru')) return 'ru'
465
+ if (tag.startsWith('en')) return 'en'
466
+ return null
467
+ }
468
+
469
+ /** Язык из окружения: DSHNC_LANG → LC_ALL → LC_MESSAGES → LANG → en. */
470
+ export function detectLang (env = process.env) {
471
+ for (const key of ['DSHNC_LANG', 'LC_ALL', 'LC_MESSAGES', 'LANG']) {
472
+ const lang = normalizeLang(env?.[key])
473
+ if (lang) return lang
474
+ }
475
+ return 'en'
476
+ }
477
+
478
+ let current = 'en'
479
+
480
+ /** Зафиксировать язык для процесса. */
481
+ export function setLang (lang) {
482
+ current = normalizeLang(lang) ?? 'en'
483
+ return current
484
+ }
485
+
486
+ /** Текущий язык. */
487
+ export function lang () {
488
+ return current
489
+ }
490
+
491
+ /**
492
+ * Перевод по ключу с подстановкой `{name}`.
493
+ * @param {string} key ключ словаря
494
+ * @param {Record<string, unknown>} [vars]
495
+ */
496
+ export function t (key, vars) {
497
+ const table = DICT[current] ?? DICT.en
498
+ const template = table[key] ?? DICT.en[key] ?? key
499
+ if (!vars) return template
500
+ return template.replace(/\{(\w+)\}/g, (match, name) => (name in vars ? String(vars[name]) : match))
501
+ }
502
+
503
+ /** Для тестов: есть ли ключ во всех языках. */
504
+ export function keys () {
505
+ return Object.keys(DICT.en)
506
+ }
507
+
508
+ /** Для тестов: словарь языка. */
509
+ export function table (lang) {
510
+ return DICT[lang] ?? {}
511
+ }