@mirta/cli 0.3.5 → 0.4.0
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/README.md +295 -78
- package/README.ru.md +303 -79
- package/dist/constants.mjs +9 -0
- package/dist/deploy.mjs +902 -0
- package/dist/index.mjs +759 -20
- package/dist/package.mjs +128 -172
- package/dist/publish.mjs +58 -38
- package/dist/release.mjs +258 -158
- package/dist/resolve.mjs +457 -0
- package/locales/en-US.json +58 -0
- package/locales/ru-RU.json +58 -0
- package/package.json +21 -9
- package/dist/github.mjs +0 -135
- package/dist/locales/en-US.json +0 -21
- package/dist/locales/ru-RU.json +0 -21
- package/dist/shell.mjs +0 -189
package/dist/deploy.mjs
ADDED
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { isString, isNumber } from '@mirta/basics';
|
|
3
|
+
import { D as DEFAULT_SSH_USERNAME, K as KNOWN_SSH_PORT, e as expandHomeDir, a as resolveSubpath, i as isExistsAsync, r as resolveConfigAsync } from './resolve.mjs';
|
|
4
|
+
import { loadEnv as loadEnv$1 } from '@mirta/env-loader';
|
|
5
|
+
import { l as logger, t, r as runCommandAsync, S as STDIO_INTERACTIVE, a as assertNoParseErrors, e as STDIO_CAPTURE_ERRORS } from './index.mjs';
|
|
6
|
+
import { resolveWorkspaceContextAsync } from '@mirta/workspace';
|
|
7
|
+
import 'node:fs/promises';
|
|
8
|
+
import 'node:os';
|
|
9
|
+
import './constants.mjs';
|
|
10
|
+
import '@mirta/package';
|
|
11
|
+
import 'node:path';
|
|
12
|
+
import 'node:path/posix';
|
|
13
|
+
import 'jsonc-parser';
|
|
14
|
+
import '@mirta/basics/object';
|
|
15
|
+
import 'prompts';
|
|
16
|
+
import '@mirta/staged-args';
|
|
17
|
+
import 'node:child_process';
|
|
18
|
+
import '@mirta/i18n';
|
|
19
|
+
import '../package.json' with { type: 'json' };
|
|
20
|
+
import '@mirta/basics/fuzzy';
|
|
21
|
+
|
|
22
|
+
let isLoaded = false;
|
|
23
|
+
/**
|
|
24
|
+
* Загружает переменные окружения из .env-файлов в проекте.
|
|
25
|
+
*
|
|
26
|
+
* Выполняется только один раз за сессию (ленивая инициализация).
|
|
27
|
+
* Загружает файлы из `rootDir` и `cwd`, применяет префиксы `WB_` и `MIRTA_`.
|
|
28
|
+
* Результат объединяется с `process.env`.
|
|
29
|
+
*
|
|
30
|
+
* @param rootDir - Корневая директория проекта.
|
|
31
|
+
* @param cwd - Текущая рабочая директория (опционально).
|
|
32
|
+
*
|
|
33
|
+
* @since 0.4.0
|
|
34
|
+
*
|
|
35
|
+
**/
|
|
36
|
+
function loadEnv(rootDir, cwd) {
|
|
37
|
+
if (isLoaded)
|
|
38
|
+
return;
|
|
39
|
+
const env = loadEnv$1({
|
|
40
|
+
cwd,
|
|
41
|
+
rootDir,
|
|
42
|
+
prefix: ['WB_', 'MIRTA_'],
|
|
43
|
+
});
|
|
44
|
+
// Объединяем с текущим process.env
|
|
45
|
+
Object.assign(process.env, env);
|
|
46
|
+
isLoaded = true;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Заменяет в строке все вхождения `${VAR_NAME}` на значения из `process.env`.
|
|
50
|
+
*
|
|
51
|
+
* Используется для подстановки переменных в строках подключения, путях и т.д.
|
|
52
|
+
*
|
|
53
|
+
* @param input - Входная строка с переменными, например: `${WB_HOST}`
|
|
54
|
+
* @returns Строка с подставленными значениями.
|
|
55
|
+
* @throws Ошибка, если переменная не определена.
|
|
56
|
+
*
|
|
57
|
+
* @since 0.4.0
|
|
58
|
+
*
|
|
59
|
+
**/
|
|
60
|
+
function replaceEnvVars(input) {
|
|
61
|
+
return input.replace(/\$\{([^}]+)\}/g, (_, key) => {
|
|
62
|
+
const value = process.env[key];
|
|
63
|
+
if (value === undefined)
|
|
64
|
+
throw new Error(`Environment variable not set: ${key}`);
|
|
65
|
+
return value;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Регулярное выражение для проверки формата времени в стиле OpenSSH.
|
|
71
|
+
*
|
|
72
|
+
* Разрешает:
|
|
73
|
+
* - Чистые числа: `600` (секунды)
|
|
74
|
+
* - С последовательностями: `10m`, `1h30m`, `2d`
|
|
75
|
+
*
|
|
76
|
+
* Запрещает: `1h30`, `30x`, `m`, `abc`.
|
|
77
|
+
*
|
|
78
|
+
* Используется для валидации параметра `ttl`.
|
|
79
|
+
*
|
|
80
|
+
* @since 0.4.0
|
|
81
|
+
*
|
|
82
|
+
**/
|
|
83
|
+
const SSH_TIME_PATTERN = /^(?:\d+[smhdw])+$|^\d+$/i;
|
|
84
|
+
/**
|
|
85
|
+
* Утверждает, что переданный объект является валидным `MirtaConnection`.
|
|
86
|
+
*
|
|
87
|
+
* Проверяет все поля на корректность типов и допустимые значения.
|
|
88
|
+
* При неудаче выбрасывает `Error` с описанием проблемы.
|
|
89
|
+
*
|
|
90
|
+
* Используется для runtime-валидации конфигурации.
|
|
91
|
+
*
|
|
92
|
+
* @param value - Частичный объект подключения.
|
|
93
|
+
* @throws Ошибка, если объект не проходит валидацию.
|
|
94
|
+
*
|
|
95
|
+
* @since 0.4.0
|
|
96
|
+
*
|
|
97
|
+
**/
|
|
98
|
+
function assertConnectionIsValid(value) {
|
|
99
|
+
if (value.type !== 'ssh')
|
|
100
|
+
throw new Error(`Only SSH connection type supported`);
|
|
101
|
+
if (value.username !== undefined && (!isString(value.username) || value.username.trim() === ''))
|
|
102
|
+
throw new Error(`username must be a non-empty string`);
|
|
103
|
+
if (value.hostname === undefined || !isString(value.hostname))
|
|
104
|
+
throw new Error(`hostname is required and must be a string`);
|
|
105
|
+
if (value.port !== undefined) {
|
|
106
|
+
const port = isString(value.port)
|
|
107
|
+
? parseInt(value.port, 10)
|
|
108
|
+
: value.port;
|
|
109
|
+
if (!isNumber(port) || !Number.isInteger(port) || port < 1 || port > 65535)
|
|
110
|
+
throw new Error(`port must be integer between 1 and 65535, got ${JSON.stringify(value.port)}`);
|
|
111
|
+
}
|
|
112
|
+
if (value.pkcs11 !== undefined && !isString(value.pkcs11))
|
|
113
|
+
throw new Error(`pkcs11: path to identity must be a string`);
|
|
114
|
+
if (value.key !== undefined && !isString(value.key))
|
|
115
|
+
throw new Error(`key: path to identity must be a string`);
|
|
116
|
+
if (value.ttl !== undefined) {
|
|
117
|
+
if (!isString(value.ttl))
|
|
118
|
+
throw new Error(`ttl must be a string`);
|
|
119
|
+
if (!SSH_TIME_PATTERN.test(value.ttl.trim()))
|
|
120
|
+
throw new Error(`ttl must be in format <number>[smhd]`);
|
|
121
|
+
}
|
|
122
|
+
if (value.wsl !== undefined && !isString(value.wsl))
|
|
123
|
+
throw new Error(`wsl: distro name must be a string`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Разделяет строку по первому вхождению указанного разделителя.
|
|
128
|
+
*
|
|
129
|
+
* @param input - Входная строка.
|
|
130
|
+
* @param separator - Разделитель.
|
|
131
|
+
* @returns Массив из двух элементов: до и после разделителя. Если разделитель не найден — возвращает `[input]`.
|
|
132
|
+
*
|
|
133
|
+
* @since 0.4.0
|
|
134
|
+
*
|
|
135
|
+
**/
|
|
136
|
+
function splitByFirstOccurrence(input, separator) {
|
|
137
|
+
const index = input.indexOf(separator);
|
|
138
|
+
if (index === -1)
|
|
139
|
+
return [input];
|
|
140
|
+
return [input.slice(0, index), input.slice(index + separator.length)];
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Парсит строку подключения в структурированный объект.
|
|
144
|
+
*
|
|
145
|
+
* Формат строки:
|
|
146
|
+
* ```txt
|
|
147
|
+
* protocol://user@host:port;param1=value1;param2=value2
|
|
148
|
+
* ```
|
|
149
|
+
* Извлекает:
|
|
150
|
+
* - Протокол, имя пользователя, хост, порт из URL-части
|
|
151
|
+
* - Дополнительные параметры из пар ключ=значение после точки с запятой:
|
|
152
|
+
* `pkcs11`, `key`, `ttl`, `wsl`
|
|
153
|
+
*
|
|
154
|
+
* Выполняет подстановку переменных окружения в формате ${VAR_NAME}.
|
|
155
|
+
*
|
|
156
|
+
* @param input - Строка подключения
|
|
157
|
+
* @returns Объект с распарсенными полями (требует последующей валидации)
|
|
158
|
+
* @throws Ошибка при невалидном URL или пустой строке
|
|
159
|
+
*
|
|
160
|
+
* @since 0.4.0
|
|
161
|
+
*
|
|
162
|
+
**/
|
|
163
|
+
function parseConnectionString(input) {
|
|
164
|
+
const source = replaceEnvVars(input).trim();
|
|
165
|
+
if (source === '')
|
|
166
|
+
throw new Error('Empty connection string');
|
|
167
|
+
const parts = source.split(';');
|
|
168
|
+
// === 1. Основные параметры ===
|
|
169
|
+
let url;
|
|
170
|
+
try {
|
|
171
|
+
url = new URL(parts[0]);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
throw new Error(`Invalid connection URL: "${parts[0]}"`);
|
|
175
|
+
}
|
|
176
|
+
const protocol = url.protocol.replace(':', '');
|
|
177
|
+
const result = {
|
|
178
|
+
type: protocol,
|
|
179
|
+
username: decodeURIComponent(url.username),
|
|
180
|
+
hostname: url.hostname,
|
|
181
|
+
};
|
|
182
|
+
if (url.port !== '')
|
|
183
|
+
result.port = url.port;
|
|
184
|
+
// === 2. Вспомогательные опции ===
|
|
185
|
+
const params = parts.slice(1).reduce((items, nextItem) => {
|
|
186
|
+
const [key, value] = splitByFirstOccurrence(nextItem, '=');
|
|
187
|
+
if (key) {
|
|
188
|
+
if (value !== undefined) {
|
|
189
|
+
items[key] = value;
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
logger.warn(t('connection.emptyParameterSkipped', { key }));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return items;
|
|
196
|
+
}, {});
|
|
197
|
+
result.pkcs11 = params.pkcs11;
|
|
198
|
+
result.key = params.key;
|
|
199
|
+
result.ttl = params.ttl;
|
|
200
|
+
if (result.ttl && !result.pkcs11 && !result.key)
|
|
201
|
+
logger.warn(t('connection.ttlSkipped'));
|
|
202
|
+
result.wsl = params.wsl;
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Разрешает имя подключения в полный объект `MirtaConnection`.
|
|
208
|
+
*
|
|
209
|
+
* Поддерживает:
|
|
210
|
+
* - Прямые строки с URL (например, `ssh://...`)
|
|
211
|
+
* - Ссылки на имена из `config.connections`
|
|
212
|
+
* - Подстановку переменных окружения
|
|
213
|
+
*
|
|
214
|
+
* Если соединение найдено, применяет значение по умолчанию для `username` и проверяет валидность.
|
|
215
|
+
*
|
|
216
|
+
* @param config - Конфигурация проекта.
|
|
217
|
+
* @param input - Имя подключения или строка с URL (по умолчанию `'default'`).
|
|
218
|
+
* @returns Полный объект подключения.
|
|
219
|
+
* @throws Ошибка, если подключение не найдено или невалидно.
|
|
220
|
+
*
|
|
221
|
+
* @since 0.4.0
|
|
222
|
+
*
|
|
223
|
+
**/
|
|
224
|
+
function resolveConnection(config, input = 'default') {
|
|
225
|
+
const inputNorm = replaceEnvVars(input);
|
|
226
|
+
let connection;
|
|
227
|
+
// Явная строка с протоколом
|
|
228
|
+
if (/^(?:[\w]+\+)?[\w]+:\/\//.test(inputNorm)) {
|
|
229
|
+
connection = inputNorm;
|
|
230
|
+
}
|
|
231
|
+
// Имя подключения из набора config.connections
|
|
232
|
+
else if (config.connections && inputNorm in config.connections) {
|
|
233
|
+
connection = config.connections[inputNorm];
|
|
234
|
+
}
|
|
235
|
+
if (!connection)
|
|
236
|
+
throw new Error(`Connection "${input}" not found`);
|
|
237
|
+
// Если строка — парсим в объект
|
|
238
|
+
if (isString(connection))
|
|
239
|
+
connection = parseConnectionString(connection);
|
|
240
|
+
if (connection.username === '' || connection.username === undefined)
|
|
241
|
+
connection.username = DEFAULT_SSH_USERNAME;
|
|
242
|
+
if (connection.ttl && isNumber(connection.ttl))
|
|
243
|
+
connection.ttl = connection.ttl.toString();
|
|
244
|
+
if (connection.port === '')
|
|
245
|
+
connection.port = undefined;
|
|
246
|
+
assertConnectionIsValid(connection);
|
|
247
|
+
return connection;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Формирует строку назначения подключения в формате `user@host[:port]`.
|
|
252
|
+
*
|
|
253
|
+
* Порт включается только если отличается от стандартного (22).
|
|
254
|
+
*
|
|
255
|
+
* @param connection - Объект подключения.
|
|
256
|
+
* @returns Строка вида `user@host` или `user@host:port`.
|
|
257
|
+
*
|
|
258
|
+
**/
|
|
259
|
+
function getConnectionTarget(connection) {
|
|
260
|
+
let target = `${connection.username}@${connection.hostname}`;
|
|
261
|
+
if (connection.port && connection.port !== KNOWN_SSH_PORT)
|
|
262
|
+
target += `:${connection.port}`;
|
|
263
|
+
return target;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Базовая директория для SSH-файлов в зависимости от платформы.
|
|
268
|
+
*
|
|
269
|
+
* На Unix-системах — домашняя директория пользователя.
|
|
270
|
+
* На Windows — используется символ `~`, который раскрывается в оболочке.
|
|
271
|
+
*
|
|
272
|
+
* Используется для формирования пути к сокету SSH-агента.
|
|
273
|
+
*
|
|
274
|
+
* @since 0.4.0
|
|
275
|
+
*
|
|
276
|
+
**/
|
|
277
|
+
const SSH_DIR = expandHomeDir('~/.ssh');
|
|
278
|
+
/**
|
|
279
|
+
* Путь к сокету изолированного ssh-agent.
|
|
280
|
+
*
|
|
281
|
+
* @since 0.4.0
|
|
282
|
+
*
|
|
283
|
+
**/
|
|
284
|
+
const SSH_AUTH_SOCK = SSH_DIR + '/mirta-agent.sock';
|
|
285
|
+
/**
|
|
286
|
+
* Время жизни ключа, используемое в ssh-agent по умолчанию.
|
|
287
|
+
*
|
|
288
|
+
* @since 0.4.0
|
|
289
|
+
*
|
|
290
|
+
**/
|
|
291
|
+
const DEFAULT_SSH_KEY_TTL = '15m';
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Асинхронно выполняет синхронизацию файлов с контроллером через `rsync` по SSH.
|
|
295
|
+
*
|
|
296
|
+
* Если исходный путь не существует — операция пропускается без ошибки.
|
|
297
|
+
* Поддерживает фильтрацию, очистку, защиту файлов и изменение группы.
|
|
298
|
+
* На Windows команда выполняется внутри WSL2.
|
|
299
|
+
*
|
|
300
|
+
* @param options - Параметры синхронизации.
|
|
301
|
+
*
|
|
302
|
+
* @since 0.4.0
|
|
303
|
+
*
|
|
304
|
+
**/
|
|
305
|
+
async function runRsyncAsync(options) {
|
|
306
|
+
const { connection, mapping, toGroup, cwd, isDryRun, } = options;
|
|
307
|
+
const from = resolveSubpath(cwd, mapping.from);
|
|
308
|
+
if (!await isExistsAsync(from)) {
|
|
309
|
+
logger.step(t('deploy.sourceNotExists', {
|
|
310
|
+
source: from,
|
|
311
|
+
}));
|
|
312
|
+
// Файлы могут отсутствовать, это допустимо.
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const to = `${connection.username}@${connection.hostname}:${mapping.to}/`;
|
|
316
|
+
const args = [];
|
|
317
|
+
const sshParts = [];
|
|
318
|
+
if (connection.port && connection.port !== KNOWN_SSH_PORT)
|
|
319
|
+
sshParts.push(`-p ${connection.port}`);
|
|
320
|
+
if (sshParts.length > 0)
|
|
321
|
+
args.push('-e', `'ssh ${sshParts.join(' ')}'`);
|
|
322
|
+
// Флаги rsync:
|
|
323
|
+
// -r: рекурсивно
|
|
324
|
+
// -t: сохранять время файлов
|
|
325
|
+
// -z: сжатие
|
|
326
|
+
// -g: сохранять группу
|
|
327
|
+
// -O: не обновлять время на директориях
|
|
328
|
+
args.push('-rtzgO');
|
|
329
|
+
if (isDryRun)
|
|
330
|
+
args.unshift('--dry-run', '--itemize-changes');
|
|
331
|
+
if (mapping.cleanup)
|
|
332
|
+
args.push('--delete');
|
|
333
|
+
mapping.exclude?.forEach((pattern) => {
|
|
334
|
+
args.push('--exclude', pattern);
|
|
335
|
+
});
|
|
336
|
+
mapping.protect?.forEach((pattern) => {
|
|
337
|
+
args.push('--filter', `P ${pattern}`);
|
|
338
|
+
});
|
|
339
|
+
if (toGroup)
|
|
340
|
+
args.push('--groupmap', `*:${toGroup}`);
|
|
341
|
+
args.push(from, to);
|
|
342
|
+
logger.step(t('deploy.transmitting', {
|
|
343
|
+
from: mapping.from,
|
|
344
|
+
to: mapping.to,
|
|
345
|
+
}));
|
|
346
|
+
await runCommandAsync.inUnixShell(connection.wsl)('rsync', [...args], {
|
|
347
|
+
env: {
|
|
348
|
+
SSH_AUTH_SOCK,
|
|
349
|
+
},
|
|
350
|
+
cwd,
|
|
351
|
+
stdio: STDIO_INTERACTIVE,
|
|
352
|
+
shell: false,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Схема доступных опций команды `deploy`.
|
|
358
|
+
*
|
|
359
|
+
* Определяет типы и сокращённые формы флагов.
|
|
360
|
+
*
|
|
361
|
+
* @since 0.4.0
|
|
362
|
+
*
|
|
363
|
+
**/
|
|
364
|
+
const options = ({
|
|
365
|
+
'config': {
|
|
366
|
+
type: 'string',
|
|
367
|
+
short: 'c',
|
|
368
|
+
},
|
|
369
|
+
'dry-run': {
|
|
370
|
+
type: 'boolean',
|
|
371
|
+
},
|
|
372
|
+
'profile': {
|
|
373
|
+
type: 'string',
|
|
374
|
+
short: 'p',
|
|
375
|
+
},
|
|
376
|
+
'to': {
|
|
377
|
+
type: 'string',
|
|
378
|
+
},
|
|
379
|
+
// Deprecated. Use 'dry-run' instead
|
|
380
|
+
'dry': {
|
|
381
|
+
type: 'boolean',
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
/**
|
|
385
|
+
* Парсит аргументы командной строки для команды `deploy`.
|
|
386
|
+
*
|
|
387
|
+
* - Обрабатывает опции в соответствии с объявленной схемой.
|
|
388
|
+
* - Если используется устаревший флаг `--dry`, выводит предупреждение и преобразует его в `--dry-run`.
|
|
389
|
+
*
|
|
390
|
+
* @param args - Объект с аргументами, управляемый `StagedArgs`.
|
|
391
|
+
* @returns Объект с распарсенными значениями и позиционными аргументами.
|
|
392
|
+
*
|
|
393
|
+
* @since 0.4.0
|
|
394
|
+
*
|
|
395
|
+
**/
|
|
396
|
+
function parseArgs(args) {
|
|
397
|
+
const parseResult = args.parseFinal(options);
|
|
398
|
+
assertNoParseErrors(parseResult);
|
|
399
|
+
const { values, positionals } = parseResult.data;
|
|
400
|
+
if (values.dry) {
|
|
401
|
+
logger.warn('Deprecated flag "--dry" used. Please use "--dry-run" instead');
|
|
402
|
+
values['dry-run'] = values['dry-run'] !== false;
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
values,
|
|
406
|
+
positionals,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Обеспечивает запуск изолированного SSH-агента для текущей сессии CLI.
|
|
412
|
+
*
|
|
413
|
+
* Проверяет, запущен ли агент. Если нет — удаляет старый сокет (если есть), создаёт директорию `~/.ssh`,
|
|
414
|
+
* и запускает новый экземпляр `ssh-agent` с ограниченным временем жизни ключей.
|
|
415
|
+
*
|
|
416
|
+
* Используется для безопасного управления ключами и токенами (PKCS#11) без влияния на основной агент системы.
|
|
417
|
+
*
|
|
418
|
+
* @param context - Контекст выполнения (включая поддержку WSL2).
|
|
419
|
+
* @throws Ошибка, если не удалось запустить `ssh-agent`.
|
|
420
|
+
*
|
|
421
|
+
* @since 0.4.0
|
|
422
|
+
*
|
|
423
|
+
**/
|
|
424
|
+
async function ensureAgentIsRunningAsync(context) {
|
|
425
|
+
try {
|
|
426
|
+
// Проверяем, отвечает ли агент
|
|
427
|
+
const result = await context.runAsync('ssh-add', ['-l'], {
|
|
428
|
+
env: {
|
|
429
|
+
SSH_AUTH_SOCK,
|
|
430
|
+
},
|
|
431
|
+
stdio: STDIO_CAPTURE_ERRORS,
|
|
432
|
+
doneCodes: [0, 1],
|
|
433
|
+
});
|
|
434
|
+
if (result.code === 0 || result.code === 1) {
|
|
435
|
+
logger.debug('SSH agent is running');
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
// Агент не отвечает — продолжаем инициализацию
|
|
441
|
+
}
|
|
442
|
+
try {
|
|
443
|
+
// Удаляем старый сокет, если существует
|
|
444
|
+
await context.runAsync('rm', ['-f', SSH_AUTH_SOCK], { stdio: STDIO_CAPTURE_ERRORS });
|
|
445
|
+
}
|
|
446
|
+
catch (e) {
|
|
447
|
+
logger.warn(`Could not remove stale socket: ${e instanceof Error ? e.message : String(e)}`);
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
// Создаём директорию ~/.ssh, если не существует
|
|
451
|
+
await context.runAsync('mkdir', ['-p', SSH_DIR], { stdio: STDIO_CAPTURE_ERRORS });
|
|
452
|
+
}
|
|
453
|
+
catch (e) {
|
|
454
|
+
logger.warn(`Could not create SSH directory: ${e instanceof Error ? e.message : String(e)}`);
|
|
455
|
+
}
|
|
456
|
+
// Аргументы для запуска ssh-agent
|
|
457
|
+
const args = ['-a', SSH_AUTH_SOCK, '-t', DEFAULT_SSH_KEY_TTL];
|
|
458
|
+
// Если используется PKCS#11, указываем путь к модулю
|
|
459
|
+
if (context.pkcs11)
|
|
460
|
+
args.push('-P', context.pkcs11);
|
|
461
|
+
try {
|
|
462
|
+
await context.runAsync('ssh-agent', args, {
|
|
463
|
+
stdio: STDIO_CAPTURE_ERRORS,
|
|
464
|
+
});
|
|
465
|
+
logger.debug('SSH agent started');
|
|
466
|
+
}
|
|
467
|
+
catch (e) {
|
|
468
|
+
throw new Error(`Failed to start ssh-agent: ${e instanceof Error ? e.message : String(e)}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Проверяет, содержится ли указанный отпечаток (или путь к токену) в списке добавленных сущностей в SSH-агенте.
|
|
474
|
+
*
|
|
475
|
+
* Использует команду `ssh-add -l`, которая выводит список всех добавленных ключей и токенов.
|
|
476
|
+
* Подходит как для проверки SSH-ключей (по отпечатку), так и для PKCS#11-токенов (по пути к библиотеке).
|
|
477
|
+
*
|
|
478
|
+
* @param fingerprint - Отпечаток ключа или путь к PKCS#11 модулю, который нужно проверить.
|
|
479
|
+
* @param context - Контекст выполнения команды (включая настройки окружения и WSL2).
|
|
480
|
+
* @returns `true`, если запись найдена в агенте, иначе `false`.
|
|
481
|
+
*
|
|
482
|
+
* @since 0.4.0
|
|
483
|
+
*
|
|
484
|
+
**/
|
|
485
|
+
async function hasEntryAsync(fingerprint, context) {
|
|
486
|
+
const response = await context.runAsync('ssh-add', ['-l'], {
|
|
487
|
+
env: {
|
|
488
|
+
SSH_AUTH_SOCK,
|
|
489
|
+
},
|
|
490
|
+
stdio: STDIO_CAPTURE_ERRORS,
|
|
491
|
+
doneCodes: [0, 1], // 0 = есть ключи, 1 = нет ключей
|
|
492
|
+
});
|
|
493
|
+
if (response.code === 1)
|
|
494
|
+
return false;
|
|
495
|
+
return response.stdout.includes(fingerprint);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Удаляет PKCS#11 токен из SSH-агента.
|
|
500
|
+
*
|
|
501
|
+
* Использует команду `ssh-add -qe <path>` для выгрузки модуля.
|
|
502
|
+
*
|
|
503
|
+
* @param path - Путь к библиотеке PKCS#11 (например, `/usr/lib/libykcs11.so`).
|
|
504
|
+
* @param context - Контекст выполнения, включая среду (WSL2) и переменные окружения.
|
|
505
|
+
* @returns `true`, если токен успешно удалён, иначе `false`.
|
|
506
|
+
*
|
|
507
|
+
* @since 0.4.0
|
|
508
|
+
*
|
|
509
|
+
**/
|
|
510
|
+
async function removeTokenAsync(path, context) {
|
|
511
|
+
try {
|
|
512
|
+
await context.runAsync('ssh-add', ['-qe', path], {
|
|
513
|
+
env: {
|
|
514
|
+
SSH_AUTH_SOCK,
|
|
515
|
+
},
|
|
516
|
+
stdio: 'ignore',
|
|
517
|
+
});
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Проверяет, добавлен ли PKCS#11 токен в SSH-агент.
|
|
526
|
+
*
|
|
527
|
+
* Анализирует вывод `ssh-add -l` на наличие пути к токену.
|
|
528
|
+
*
|
|
529
|
+
* @param path - Путь к библиотеке PKCS#11.
|
|
530
|
+
* @param context - Контекст выполнения.
|
|
531
|
+
* @returns `true`, если токен найден в агенте, иначе `false`.
|
|
532
|
+
*
|
|
533
|
+
* @since 0.4.0
|
|
534
|
+
*
|
|
535
|
+
**/
|
|
536
|
+
async function hasTokenAsync(path, context) {
|
|
537
|
+
// Для PKCS#11 токенов ssh-add -l выводит путь к библиотеке,
|
|
538
|
+
// поэтому можем проверить наличие через простой поиск строки
|
|
539
|
+
return await hasEntryAsync(path, context);
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Добавляет PKCS#11 токен в SSH-агент.
|
|
543
|
+
*
|
|
544
|
+
* Использует `ssh-add -s <path>`, с опциональным указанием времени жизни (`-t`).
|
|
545
|
+
* Вывод команды передаётся в терминал для отображения подсказок (например, ввод PIN-кода).
|
|
546
|
+
*
|
|
547
|
+
* @param path - Путь к библиотеке PKCS#11.
|
|
548
|
+
* @param context - Контекст выполнения.
|
|
549
|
+
* @throws Ошибка, если команда завершилась с кодом, отличным от 0.
|
|
550
|
+
*
|
|
551
|
+
* @since 0.4.0
|
|
552
|
+
*
|
|
553
|
+
**/
|
|
554
|
+
async function addTokenAsync(path, context) {
|
|
555
|
+
const args = ['-q'];
|
|
556
|
+
if (context.ttl)
|
|
557
|
+
args.push('-t', context.ttl.toString());
|
|
558
|
+
args.push('-s', path);
|
|
559
|
+
await context.runAsync('ssh-add', args, {
|
|
560
|
+
env: {
|
|
561
|
+
SSH_AUTH_SOCK,
|
|
562
|
+
},
|
|
563
|
+
stdio: STDIO_INTERACTIVE,
|
|
564
|
+
cancelCodes: [2, 130],
|
|
565
|
+
});
|
|
566
|
+
logger.debug('PKCS#11 token added to ssh-agent');
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Получает отпечаток (fingerprint) приватного SSH-ключа с помощью `ssh-keygen -lf`.
|
|
571
|
+
*
|
|
572
|
+
* Используется для идентификации ключа перед проверкой его наличия в агенте.
|
|
573
|
+
*
|
|
574
|
+
* @param key - Путь к приватному SSH-ключу.
|
|
575
|
+
* @param context - Контекст выполнения (включая поддержку WSL2).
|
|
576
|
+
* @returns Отпечаток ключа в формате, выводимом `ssh-keygen`.
|
|
577
|
+
* @throws Ошибка, если не удалось получить или распарсить вывод.
|
|
578
|
+
*
|
|
579
|
+
* @since 0.4.0
|
|
580
|
+
*
|
|
581
|
+
**/
|
|
582
|
+
async function getFingerprintAsync(key, context) {
|
|
583
|
+
const response = await context.runAsync('ssh-keygen', ['-lf', key]);
|
|
584
|
+
const output = response.stdout.trim();
|
|
585
|
+
if (!output)
|
|
586
|
+
throw new Error('No data from ssh-keygen');
|
|
587
|
+
const fingerprint = output.split(' ')[1];
|
|
588
|
+
if (!fingerprint)
|
|
589
|
+
throw new Error('No fingerprint in ssh-keygen output');
|
|
590
|
+
return fingerprint;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Проверяет, добавлен ли SSH-ключ в агент.
|
|
594
|
+
*
|
|
595
|
+
* Сравнивает отпечаток ключа с отпечатками, возвращёнными `ssh-add -l`.
|
|
596
|
+
*
|
|
597
|
+
* @param path - Путь к приватному SSH-ключу.
|
|
598
|
+
* @param context - Контекст выполнения.
|
|
599
|
+
* @returns `true`, если ключ найден в агенте, иначе `false`.
|
|
600
|
+
*
|
|
601
|
+
* @since 0.4.0
|
|
602
|
+
*
|
|
603
|
+
**/
|
|
604
|
+
async function hasKeyAsync(path, context) {
|
|
605
|
+
const fingerprint = await getFingerprintAsync(path, context);
|
|
606
|
+
return await hasEntryAsync(fingerprint, context);
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Добавляет приватный SSH-ключ в SSH-агент.
|
|
610
|
+
*
|
|
611
|
+
* Использует `ssh-add` с опциональным временем жизни (`-t`) из контекста.
|
|
612
|
+
* Вывод команды передаётся в терминал для отображения подсказок (например, ввод пароля).
|
|
613
|
+
*
|
|
614
|
+
* @param path - Путь к приватному ключу.
|
|
615
|
+
* @param context - Контекст выполнения.
|
|
616
|
+
* @throws Ошибка, если команда завершилась с кодом, отличным от 0.
|
|
617
|
+
*
|
|
618
|
+
* @since 0.4.0
|
|
619
|
+
*
|
|
620
|
+
**/
|
|
621
|
+
async function addKeyAsync(path, context) {
|
|
622
|
+
const args = ['-q'];
|
|
623
|
+
if (context.ttl)
|
|
624
|
+
args.push('-t', context.ttl);
|
|
625
|
+
args.push(expandHomeDir(path));
|
|
626
|
+
await context.runAsync('ssh-add', args, {
|
|
627
|
+
env: {
|
|
628
|
+
SSH_AUTH_SOCK,
|
|
629
|
+
},
|
|
630
|
+
stdio: STDIO_INTERACTIVE,
|
|
631
|
+
cancelCodes: [2, 130],
|
|
632
|
+
});
|
|
633
|
+
logger.debug('SSH key added to ssh-agent');
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Выполняет аутентификацию подключения к контроллеру через SSH-агент.
|
|
638
|
+
*
|
|
639
|
+
* Добавляет в ssh-agent либо:
|
|
640
|
+
* - PKCS#11 токен (например, Rutoken), если указан `connection.pkcs11`
|
|
641
|
+
* - Приватный ключ, если указан `connection.key`
|
|
642
|
+
*
|
|
643
|
+
* Приоритет — у PKCS#11. Агент запускается автоматически при необходимости.
|
|
644
|
+
* Поддерживает выполнение в WSL2 на Windows.
|
|
645
|
+
*
|
|
646
|
+
* @param connection - Конфигурация подключения, содержащая параметры аутентификации.
|
|
647
|
+
*
|
|
648
|
+
* @since 0.4.0
|
|
649
|
+
*
|
|
650
|
+
**/
|
|
651
|
+
async function authenticateAsync(connection) {
|
|
652
|
+
if (connection.type !== 'ssh')
|
|
653
|
+
return;
|
|
654
|
+
const context = {
|
|
655
|
+
pkcs11: connection.pkcs11,
|
|
656
|
+
key: connection.key,
|
|
657
|
+
ttl: connection.ttl,
|
|
658
|
+
runAsync: runCommandAsync.inUnixShell(connection.wsl),
|
|
659
|
+
};
|
|
660
|
+
// Ленивая инициализация агента SSH - только если это имеет смысл.
|
|
661
|
+
if (context.pkcs11 || context.key) {
|
|
662
|
+
await ensureAgentIsRunningAsync(context);
|
|
663
|
+
// Приоритет pkcs11 над key для кросс-машинной совместимости.
|
|
664
|
+
// TODO: добавить fallback на key, если токен pkcs11 не обнаружен.
|
|
665
|
+
//
|
|
666
|
+
if (context.pkcs11) {
|
|
667
|
+
const hasToken = await hasTokenAsync(context.pkcs11, context);
|
|
668
|
+
if (!hasToken) {
|
|
669
|
+
// Если срок действия токена истёк —
|
|
670
|
+
// выгружаем модуль, иначе повторно не добавить.
|
|
671
|
+
//
|
|
672
|
+
const isRemoved = await removeTokenAsync(context.pkcs11, context);
|
|
673
|
+
if (isRemoved)
|
|
674
|
+
logger.debug('Stale PKCS#11 module unloaded');
|
|
675
|
+
await addTokenAsync(context.pkcs11, context);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
else if (context.key) {
|
|
679
|
+
const hasKey = await hasKeyAsync(context.key, context);
|
|
680
|
+
if (!hasKey)
|
|
681
|
+
await addKeyAsync(context.key, context);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Проверяет, существует ли указанная группа на удалённом контроллере Wiren Board.
|
|
688
|
+
*
|
|
689
|
+
* Использует команду `getent group <group>` через SSH для проверки наличия группы.
|
|
690
|
+
* Поддерживает выполнение через WSL2 на Windows.
|
|
691
|
+
*
|
|
692
|
+
* @param group - Имя группы (например, 'wb-users').
|
|
693
|
+
* @param connection - Конфигурация подключения к контроллеру.
|
|
694
|
+
* @returns `true`, если группа найдена, иначе `false`.
|
|
695
|
+
*
|
|
696
|
+
* @since 0.4.0
|
|
697
|
+
*
|
|
698
|
+
**/
|
|
699
|
+
async function hasRemoteGroupAsync(group, connection) {
|
|
700
|
+
const { hostname, username, port } = connection;
|
|
701
|
+
const args = [];
|
|
702
|
+
if (port)
|
|
703
|
+
args.push('-p', String(port));
|
|
704
|
+
args.push(`${username}@${hostname}`, `getent group ${group} > /dev/null 2>&1`);
|
|
705
|
+
try {
|
|
706
|
+
const result = await runCommandAsync.inUnixShell(connection.wsl)('ssh', args, {
|
|
707
|
+
env: {
|
|
708
|
+
SSH_AUTH_SOCK,
|
|
709
|
+
},
|
|
710
|
+
stdio: STDIO_CAPTURE_ERRORS,
|
|
711
|
+
doneCodes: [0, 2],
|
|
712
|
+
cancelCodes: [130],
|
|
713
|
+
});
|
|
714
|
+
return result.code === 0;
|
|
715
|
+
}
|
|
716
|
+
catch (e) {
|
|
717
|
+
logger.warn(e instanceof Error ? e.message : String(e));
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Рекомендуемая группа для безопасного развертывания.
|
|
724
|
+
*
|
|
725
|
+
* Отдельная группа изолирует разработчиков,
|
|
726
|
+
* предотвращая случайное изменение или удаление важных системных файлов.
|
|
727
|
+
*
|
|
728
|
+
* @example
|
|
729
|
+
* ```bash
|
|
730
|
+
* sudo groupadd -f developers
|
|
731
|
+
* sudo usermod -aG developers deploy-user
|
|
732
|
+
* ```
|
|
733
|
+
*
|
|
734
|
+
* @since 0.4.0
|
|
735
|
+
*
|
|
736
|
+
**/
|
|
737
|
+
const RECOMMENDED_GROUP = 'developers';
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Асинхронно проверяет, что WSL2 корректно настроен для выполнения команд.
|
|
741
|
+
*
|
|
742
|
+
* - Выполняет `wsl --list --verbose` через PowerShell
|
|
743
|
+
* - Анализирует список дистрибутивов
|
|
744
|
+
* - Проверяет версию WSL для указанного или дистрибутива по умолчанию
|
|
745
|
+
*
|
|
746
|
+
* Выбрасывает локализованные ошибки при проблемах.
|
|
747
|
+
*
|
|
748
|
+
* @param connection - Подключение, которое может указывать на конкретный дистрибутив WSL.
|
|
749
|
+
* @throws Ошибка с текстом на русском языке, если:
|
|
750
|
+
* - WSL не установлен
|
|
751
|
+
* - Нет дистрибутивов
|
|
752
|
+
* - Указанный дистрибутив не найден
|
|
753
|
+
* - Дистрибутив не WSL2
|
|
754
|
+
* - Дистрибутив по умолчанию не WSL2
|
|
755
|
+
*
|
|
756
|
+
* @since 0.4.0
|
|
757
|
+
*
|
|
758
|
+
**/
|
|
759
|
+
async function assertWsl2ConfiguredAsync(connection) {
|
|
760
|
+
try {
|
|
761
|
+
// Запускаем wsl --list --verbose через PowerShell
|
|
762
|
+
const { stdout } = await runCommandAsync('powershell', ['$env:WSL_UTF8=1;', 'wsl', '--list', '--verbose']);
|
|
763
|
+
const lines = stdout.split('\n').slice(1); // Пропускаем заголовок
|
|
764
|
+
const distros = new Map();
|
|
765
|
+
let defaultDistro;
|
|
766
|
+
// Парсим каждую строку вывода.
|
|
767
|
+
for (const line of lines) {
|
|
768
|
+
const match = /^(\*)?\s+(\S+)\s+(?:\S+)\s+(\d+)$/
|
|
769
|
+
.exec(line.trim());
|
|
770
|
+
if (!match)
|
|
771
|
+
continue;
|
|
772
|
+
const distro = {
|
|
773
|
+
name: match[2],
|
|
774
|
+
version: parseInt(match[3], 10),
|
|
775
|
+
isDefault: match[1] === '*',
|
|
776
|
+
};
|
|
777
|
+
if (distro.isDefault)
|
|
778
|
+
defaultDistro = distro;
|
|
779
|
+
distros.set(distro.name.toLowerCase(), distro);
|
|
780
|
+
}
|
|
781
|
+
if (distros.size === 0)
|
|
782
|
+
throw new Error(t('wsl.noDistros'));
|
|
783
|
+
if (connection.wsl) {
|
|
784
|
+
const targetDistro = distros.get(connection.wsl.toLowerCase());
|
|
785
|
+
if (!targetDistro)
|
|
786
|
+
throw new Error(t('wsl.distroNotFound', { name: connection.wsl }));
|
|
787
|
+
if (targetDistro.version < 2)
|
|
788
|
+
throw new Error(t('wsl.distroNotWsl2', { name: connection.wsl }));
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (!defaultDistro)
|
|
792
|
+
throw new Error(t('wsl.noDefault'));
|
|
793
|
+
if (defaultDistro.version >= 2)
|
|
794
|
+
return;
|
|
795
|
+
throw new Error(t('wsl.distroNotWsl2', { name: defaultDistro.name }));
|
|
796
|
+
}
|
|
797
|
+
catch (e) {
|
|
798
|
+
// Пробрасываем внутренние ошибки валидации как есть.
|
|
799
|
+
if (e instanceof Error && !('code' in e))
|
|
800
|
+
throw e;
|
|
801
|
+
if (e instanceof Error && 'code' in e && e.code === 'ENOENT')
|
|
802
|
+
throw new Error(t('wsl.notInstalled'));
|
|
803
|
+
throw new Error(t('wsl.error', {
|
|
804
|
+
error: e instanceof Error ? e.message : String(e),
|
|
805
|
+
}));
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const { yellow } = chalk;
|
|
810
|
+
/**
|
|
811
|
+
* Асинхронно выполняет команду `deploy`.
|
|
812
|
+
*
|
|
813
|
+
* - Парсит аргументы
|
|
814
|
+
* - Загружает конфигурацию
|
|
815
|
+
* - Настраивает подключение и аутентификацию
|
|
816
|
+
* - Проверяет окружение (WSL2)
|
|
817
|
+
* - Выполняет синхронизацию файлов по заданным маппингам
|
|
818
|
+
*
|
|
819
|
+
* @param args - Аргументы командной строки, управляемые `StagedArgs`.
|
|
820
|
+
*
|
|
821
|
+
* @since 0.4.0
|
|
822
|
+
*
|
|
823
|
+
**/
|
|
824
|
+
async function runAsync(args) {
|
|
825
|
+
const { values: argv } = parseArgs(args);
|
|
826
|
+
const isDryRun = argv['dry-run'];
|
|
827
|
+
// Определение профиля деплоя.
|
|
828
|
+
const profileName = argv.profile && isString(argv.profile)
|
|
829
|
+
? argv.profile
|
|
830
|
+
: 'default';
|
|
831
|
+
const cwd = process.cwd();
|
|
832
|
+
// Определение корневой директории проекта.
|
|
833
|
+
const context = await resolveWorkspaceContextAsync(cwd);
|
|
834
|
+
const rootDir = context.rootDir;
|
|
835
|
+
// Загрузка и объединение конфигурации.
|
|
836
|
+
const { config, userConfig } = await resolveConfigAsync(rootDir, argv.config);
|
|
837
|
+
// Полный набор маппингов из конфигурации.
|
|
838
|
+
const mappingPresets = config.deploy?.mappings ?? {};
|
|
839
|
+
// Используемый профиль.
|
|
840
|
+
const profile = config.deploy?.profiles?.[profileName];
|
|
841
|
+
const isImplicitProfile = !userConfig?.deploy?.profiles?.[profileName];
|
|
842
|
+
if (!profile)
|
|
843
|
+
throw new Error(t('deploy.profileNotFound', { name: profileName }));
|
|
844
|
+
// Загрузка переменных окружения ДО разрешения подключения.
|
|
845
|
+
loadEnv(rootDir, cwd);
|
|
846
|
+
// Определение подключения: из CLI-аргумента или профиля.
|
|
847
|
+
const connection = resolveConnection(config, argv.to ?? profile.connection);
|
|
848
|
+
// Проверка WSL2 на Windows.
|
|
849
|
+
if (process.platform === 'win32')
|
|
850
|
+
await assertWsl2ConfiguredAsync(connection);
|
|
851
|
+
// Логирование начала операции.
|
|
852
|
+
logger.log(t('deploy.deploying', {
|
|
853
|
+
target: yellow(getConnectionTarget(connection)),
|
|
854
|
+
mode: yellow(isDryRun ? 'dry-run' : 'live'),
|
|
855
|
+
profile: isImplicitProfile
|
|
856
|
+
? t('deploy.profileImplicit', { name: yellow(profileName) })
|
|
857
|
+
: yellow(profileName),
|
|
858
|
+
}));
|
|
859
|
+
// Аутентификация через ssh-agent (PKCS#11 или ключ).
|
|
860
|
+
await authenticateAsync(connection);
|
|
861
|
+
if (!profile.toGroup) {
|
|
862
|
+
// Если группа не указана явно,
|
|
863
|
+
// то проверяем наличие рекомендуемой группы.
|
|
864
|
+
const isGroupExists = await hasRemoteGroupAsync(RECOMMENDED_GROUP, connection);
|
|
865
|
+
if (isGroupExists) {
|
|
866
|
+
profile.toGroup = RECOMMENDED_GROUP;
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
// Если группы на контроллере нет,
|
|
870
|
+
// то выводим рекомендацию использовать отдельную группу.
|
|
871
|
+
logger.warn(t('deploy.useDedicatedGroup', { group: RECOMMENDED_GROUP }));
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
// Выполнение синхронизации по маппингам.
|
|
875
|
+
for (const key of profile.mappings ?? []) {
|
|
876
|
+
if (!(key in mappingPresets))
|
|
877
|
+
throw new Error(t('deploy.mappingsNotFound', { key }));
|
|
878
|
+
const mappings = mappingPresets[key];
|
|
879
|
+
for (const mapping of mappings) {
|
|
880
|
+
if (mapping.enabled === false) {
|
|
881
|
+
logger.log(t('deploy.mappingDisabled', {
|
|
882
|
+
from: mapping.from,
|
|
883
|
+
to: mapping.to,
|
|
884
|
+
}));
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
await runRsyncAsync({
|
|
888
|
+
mapping,
|
|
889
|
+
toGroup: mapping.toGroup ?? profile.toGroup,
|
|
890
|
+
connection,
|
|
891
|
+
cwd,
|
|
892
|
+
isDryRun,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
// Финальное сообщение.
|
|
897
|
+
logger.success(isDryRun
|
|
898
|
+
? t('deploy.simulationComplete')
|
|
899
|
+
: t('deploy.successful'));
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
export { runAsync };
|