@mirta/cli 0.4.4 → 0.4.6
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/dist/deploy.mjs +162 -1
- package/dist/index.mjs +20 -2
- package/locales/en-US.json +7 -1
- package/locales/ru-RU.json +7 -1
- package/package.json +11 -8
package/dist/deploy.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import chalk from 'chalk';
|
|
|
2
2
|
import { isString, isNumber } from '@mirta/basics';
|
|
3
3
|
import { D as DEFAULT_SSH_USERNAME, K as KNOWN_SSH_PORT, e as expandHomeDir, r as resolveConfigAsync } from './resolve.mjs';
|
|
4
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, f as STDIO_CAPTURE_OUTPUT } from './index.mjs';
|
|
5
|
+
import { l as logger, t, r as runCommandAsync, S as STDIO_INTERACTIVE, a as assertNoParseErrors, e as STDIO_CAPTURE_ERRORS, f as STDIO_CAPTURE_OUTPUT, p as prompts, O as OperationCanceledError, h as STDIO_PIPED } from './index.mjs';
|
|
6
6
|
import { resolveWorkspaceContextAsync } from '@mirta/workspace';
|
|
7
7
|
import 'node:fs/promises';
|
|
8
8
|
import 'node:os';
|
|
@@ -625,6 +625,165 @@ async function addKeyAsync(path, context) {
|
|
|
625
625
|
logger.debug('SSH key added to ssh-agent');
|
|
626
626
|
}
|
|
627
627
|
|
|
628
|
+
/**
|
|
629
|
+
* Приоритетные типы SSH-ключей, используемые при выборе ключа хоста.
|
|
630
|
+
*
|
|
631
|
+
* Определяет порядок предпочтения: от наиболее безопасного и современного к устаревшему.
|
|
632
|
+
* Используется для выбора одного ключа, если хост предоставляет несколько.
|
|
633
|
+
*
|
|
634
|
+
* @since 0.4.5
|
|
635
|
+
*
|
|
636
|
+
**/
|
|
637
|
+
const HOST_KEY_PRIORITY = [
|
|
638
|
+
'ssh-ed25519',
|
|
639
|
+
'ecdsa-sha2-nistp256',
|
|
640
|
+
'ssh-rsa',
|
|
641
|
+
];
|
|
642
|
+
/**
|
|
643
|
+
* Проверяет, содержит ли локальный файл `known_hosts` запись для указанного хоста.
|
|
644
|
+
* Использует утилиту `ssh-keygen -F <hostname>`, которая безопасно ищет хост без подключения.
|
|
645
|
+
*
|
|
646
|
+
* @param context - Контекст аутентификации, содержащий имя хоста и метод выполнения команд.
|
|
647
|
+
* @returns `true`, если хост найден в `~/.ssh/known_hosts`, иначе `false`.
|
|
648
|
+
*
|
|
649
|
+
* @remarks
|
|
650
|
+
* Ошибки выполнения команды интерпретируются как отсутствие записи.
|
|
651
|
+
* Подходит для использования перед установлением SSH-соединения.
|
|
652
|
+
*
|
|
653
|
+
* @since 0.4.5
|
|
654
|
+
*
|
|
655
|
+
**/
|
|
656
|
+
async function hasKnownHostAsync(context) {
|
|
657
|
+
try {
|
|
658
|
+
const response = await context.runAsync('ssh-keygen', ['-F', context.hostname], {
|
|
659
|
+
stdio: STDIO_CAPTURE_OUTPUT,
|
|
660
|
+
});
|
|
661
|
+
return response.stdout.length > 0;
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
return false;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Получает публичные ключи удалённого хоста с помощью `ssh-keyscan` и выбирает наиболее приоритетный.
|
|
669
|
+
* Поддерживает несколько типов ключей и выбирает первый по списку приоритетов.
|
|
670
|
+
* Для выбранного ключа вычисляется отпечаток с помощью `ssh-keygen -lf`.
|
|
671
|
+
*
|
|
672
|
+
* @param context - Контекст аутентификации.
|
|
673
|
+
* @returns Объект `HostKey`, содержащий тип, запись и отпечаток ключа, или `undefined`, если ключи недоступны.
|
|
674
|
+
*
|
|
675
|
+
* @remarks
|
|
676
|
+
* - Пропускает комментарии (строки с `#`) и невалидные записи.
|
|
677
|
+
* - Возвращает первый ключ по порядку приоритета.
|
|
678
|
+
*
|
|
679
|
+
* @since 0.4.5
|
|
680
|
+
*
|
|
681
|
+
**/
|
|
682
|
+
async function fetchHostKeyAsync(context) {
|
|
683
|
+
let result;
|
|
684
|
+
try {
|
|
685
|
+
result = await context.runAsync('ssh-keyscan', [
|
|
686
|
+
// Приоритет ключей
|
|
687
|
+
'-t', HOST_KEY_PRIORITY.join(','),
|
|
688
|
+
// Хэшировать хост, таймаут выполнения 5 секунд
|
|
689
|
+
'-HT5',
|
|
690
|
+
// Сканируемый хост
|
|
691
|
+
context.hostname,
|
|
692
|
+
], {
|
|
693
|
+
stdio: STDIO_CAPTURE_OUTPUT,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const entries = result.stdout.trim().split('\n').filter(Boolean);
|
|
700
|
+
const keys = [];
|
|
701
|
+
for (const entry of entries) {
|
|
702
|
+
if (entry.startsWith('#'))
|
|
703
|
+
continue;
|
|
704
|
+
const [host, type, key] = entry.split(/\s+/);
|
|
705
|
+
if (!host || !type || !key)
|
|
706
|
+
continue;
|
|
707
|
+
if (!HOST_KEY_PRIORITY.includes(type))
|
|
708
|
+
continue;
|
|
709
|
+
const fingerprintResult = await context.runAsync('ssh-keygen', ['-lf', '-'], {
|
|
710
|
+
stdio: STDIO_PIPED,
|
|
711
|
+
input: `${type} ${key}`,
|
|
712
|
+
});
|
|
713
|
+
const fingerprint = fingerprintResult
|
|
714
|
+
.stdout.trim().split(/\s+/)[1];
|
|
715
|
+
if (!fingerprint?.startsWith('SHA256:'))
|
|
716
|
+
continue;
|
|
717
|
+
keys.push({ type, entry, fingerprint });
|
|
718
|
+
}
|
|
719
|
+
keys.sort((a, b) => HOST_KEY_PRIORITY.indexOf(a.type) - HOST_KEY_PRIORITY.indexOf(b.type));
|
|
720
|
+
return keys[0];
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Добавляет запись о публичном ключе хоста в локальный файл `~/.ssh/known_hosts`.
|
|
724
|
+
* Использует утилиту `tee` с флагом `-a` для добавления строки в конец файла.
|
|
725
|
+
*
|
|
726
|
+
* @param key - Объект {@link HostKey}, содержащий запись ключа.
|
|
727
|
+
* @param context - Контекст аутентификации.
|
|
728
|
+
* @returns Промис, который завершается после попытки записи.
|
|
729
|
+
*
|
|
730
|
+
* @remarks
|
|
731
|
+
* Запись добавляется с символом перевода строки (`\n`) для корректного форматирования файла.
|
|
732
|
+
*
|
|
733
|
+
* @since 0.4.5
|
|
734
|
+
*
|
|
735
|
+
**/
|
|
736
|
+
async function addToKnownHostsAsync(key, context) {
|
|
737
|
+
await context.runAsync('mkdir', ['-p', SSH_DIR], {
|
|
738
|
+
stdio: STDIO_CAPTURE_ERRORS,
|
|
739
|
+
});
|
|
740
|
+
await context.runAsync('tee', ['-a', `${SSH_DIR}/known_hosts`], {
|
|
741
|
+
stdio: STDIO_PIPED,
|
|
742
|
+
input: `${key.entry}\n`,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Полностью управляет процессом подтверждения доверия к SSH-хосту.
|
|
747
|
+
* Проверяет, известен ли хост; если нет — получает ключ, показывает отпечаток и запрашивает подтверждение.
|
|
748
|
+
* При положительном ответе добавляет хост в `known_hosts`.
|
|
749
|
+
*
|
|
750
|
+
* @param context - Контекст аутентификации.
|
|
751
|
+
* @returns Промис, который завершается успешно при доверии или выбрасывает {@link OperationCanceledError} при отказе.
|
|
752
|
+
*
|
|
753
|
+
* @throws {OperationCanceledError} — если пользователь отказался доверять хосту.
|
|
754
|
+
* @throws {Error} — если не удалось получить публичный ключ хоста.
|
|
755
|
+
*
|
|
756
|
+
* @remarks
|
|
757
|
+
* Является фасадом для {@link hasKnownHostAsync}, {@link fetchHostKeyAsync} и {@link addToKnownHostsAsync}.
|
|
758
|
+
* Использует интерактивный ввод через {@link prompts}.
|
|
759
|
+
*
|
|
760
|
+
* @since 0.4.5
|
|
761
|
+
*
|
|
762
|
+
**/
|
|
763
|
+
async function confirmHost(context) {
|
|
764
|
+
if (await hasKnownHostAsync(context))
|
|
765
|
+
return;
|
|
766
|
+
const hostKey = await fetchHostKeyAsync(context);
|
|
767
|
+
if (!hostKey)
|
|
768
|
+
throw new Error('Unable to fetch host public key');
|
|
769
|
+
logger.warn([
|
|
770
|
+
t('ssh.hostUntrusted', { hostname: context.hostname }) + '\n',
|
|
771
|
+
t('ssh.keyType', { type: hostKey.type }) + '\n',
|
|
772
|
+
t('ssh.fingerprint', { fingerprint: hostKey.fingerprint }),
|
|
773
|
+
]);
|
|
774
|
+
const { canAddToKnown } = await prompts({
|
|
775
|
+
type: 'toggle',
|
|
776
|
+
name: 'canAddToKnown',
|
|
777
|
+
message: chalk.red(t('ssh.confirmHostIsTrusted')),
|
|
778
|
+
initial: false,
|
|
779
|
+
active: t('yes'),
|
|
780
|
+
inactive: t('no'),
|
|
781
|
+
});
|
|
782
|
+
if (!canAddToKnown)
|
|
783
|
+
throw new OperationCanceledError();
|
|
784
|
+
await addToKnownHostsAsync(hostKey, context);
|
|
785
|
+
}
|
|
786
|
+
|
|
628
787
|
/**
|
|
629
788
|
* Выполняет аутентификацию подключения к контроллеру через SSH-агент.
|
|
630
789
|
*
|
|
@@ -644,11 +803,13 @@ async function authenticateAsync(connection) {
|
|
|
644
803
|
if (connection.type !== 'ssh')
|
|
645
804
|
return;
|
|
646
805
|
const context = {
|
|
806
|
+
hostname: connection.hostname,
|
|
647
807
|
pkcs11: connection.pkcs11,
|
|
648
808
|
key: connection.key,
|
|
649
809
|
ttl: connection.ttl,
|
|
650
810
|
runAsync: runCommandAsync.inUnixShell(connection.wsl),
|
|
651
811
|
};
|
|
812
|
+
await confirmHost(context);
|
|
652
813
|
// Ленивая инициализация агента SSH - только если это имеет смысл.
|
|
653
814
|
if (context.pkcs11 || context.key) {
|
|
654
815
|
await ensureAgentIsRunningAsync(context);
|
package/dist/index.mjs
CHANGED
|
@@ -340,6 +340,13 @@ const logger = {
|
|
|
340
340
|
},
|
|
341
341
|
};
|
|
342
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Режим `stdio`: ввод и вывод перенаправляются в `stdin`, `stdout` и `stderr` соответственно.
|
|
345
|
+
*
|
|
346
|
+
* @since 0.4.5
|
|
347
|
+
*
|
|
348
|
+
**/
|
|
349
|
+
const STDIO_PIPED = ['pipe', 'pipe', 'pipe'];
|
|
343
350
|
/**
|
|
344
351
|
* Режим `stdio`: ввод и вывод наследуются от родительского процесса (терминал), `stderr` перехватывается.
|
|
345
352
|
*
|
|
@@ -415,9 +422,15 @@ class OperationCanceledError extends Error {
|
|
|
415
422
|
**/
|
|
416
423
|
async function execAsync(command, args = [], options = {}) {
|
|
417
424
|
return new Promise((resolve, reject) => {
|
|
418
|
-
const { doneCodes = [0], cancelCodes = [130], ...spawnOptions } = options;
|
|
425
|
+
const { doneCodes = [0], cancelCodes = [130], input, ...spawnOptions } = options;
|
|
419
426
|
spawnOptions.stdio ??= STDIO_CAPTURE_OUTPUT;
|
|
420
427
|
spawnOptions.shell ??= false;
|
|
428
|
+
const stdio = spawnOptions.stdio;
|
|
429
|
+
const stdinMode = Array.isArray(stdio) ? stdio[0] : stdio;
|
|
430
|
+
if (input !== undefined && stdinMode !== 'pipe') {
|
|
431
|
+
reject(new ShellError('Input can only be piped to stdin when stdio[0] is set to "pipe"'));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
421
434
|
const runner = spawn(command, args, spawnOptions);
|
|
422
435
|
const stdoutChunks = [];
|
|
423
436
|
const stderrChunks = [];
|
|
@@ -427,6 +440,11 @@ async function execAsync(command, args = [], options = {}) {
|
|
|
427
440
|
runner.stderr?.on('data', (chunk) => {
|
|
428
441
|
stderrChunks.push(chunk);
|
|
429
442
|
});
|
|
443
|
+
if (input !== undefined) {
|
|
444
|
+
runner.stdin?.on('error', reject);
|
|
445
|
+
runner.stdin?.write(input);
|
|
446
|
+
runner.stdin?.end();
|
|
447
|
+
}
|
|
430
448
|
runner.on('error', reject);
|
|
431
449
|
runner.on('exit', (code) => {
|
|
432
450
|
const isDone = code !== null && doneCodes.includes(code);
|
|
@@ -787,4 +805,4 @@ run().catch((e) => {
|
|
|
787
805
|
process.exit(1);
|
|
788
806
|
});
|
|
789
807
|
|
|
790
|
-
export { STDIO_INTERACTIVE as S, assertNoParseErrors as a, assertIsSyncedWithRemoteAsync as b, assertWorkflowResultAsync as c, checkIsInWorkTreeAsync as d, STDIO_CAPTURE_ERRORS as e, STDIO_CAPTURE_OUTPUT as f, getRepositoryDetails as g, logger as l, prompts as p, runCommandAsync as r, t };
|
|
808
|
+
export { OperationCanceledError as O, STDIO_INTERACTIVE as S, assertNoParseErrors as a, assertIsSyncedWithRemoteAsync as b, assertWorkflowResultAsync as c, checkIsInWorkTreeAsync as d, STDIO_CAPTURE_ERRORS as e, STDIO_CAPTURE_OUTPUT as f, getRepositoryDetails as g, STDIO_PIPED as h, logger as l, prompts as p, runCommandAsync as r, t };
|
package/locales/en-US.json
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
+
"yes": "yes",
|
|
3
|
+
"no": "no",
|
|
2
4
|
"command.suggest": "{input} → did you mean {suggestion}?",
|
|
3
5
|
"command.notFound": "Unknown command: {input}",
|
|
4
6
|
"args.errorHeader": "Invalid CLI {count, plural, one{argument} other{arguments}}",
|
|
@@ -54,5 +56,9 @@
|
|
|
54
56
|
"deploy.sourceNotExists": "Skipping non-existent: {source}",
|
|
55
57
|
"deploy.transmitting": "Transmitting {from} → {to}",
|
|
56
58
|
"deploy.simulationComplete": "Simulation complete. No changes applied.",
|
|
57
|
-
"deploy.successful": "🎉 Deployment successful!"
|
|
59
|
+
"deploy.successful": "🎉 Deployment successful!",
|
|
60
|
+
"ssh.hostUntrusted": "Host {hostname} is untrusted",
|
|
61
|
+
"ssh.keyType": "Key type: {type}",
|
|
62
|
+
"ssh.fingerprint": "Fingerprint: {fingerprint}",
|
|
63
|
+
"ssh.confirmHostIsTrusted": "Is this host trusted?"
|
|
58
64
|
}
|
package/locales/ru-RU.json
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
+
"yes": "да",
|
|
3
|
+
"no": "нет",
|
|
2
4
|
"command.suggest": "{input} → возможно, {suggestion}?",
|
|
3
5
|
"command.notFound": "Неизвестная команда: {input}",
|
|
4
6
|
"args.errorHeader": "{count, plural, one{Недопустимый аргумент} other{Недопустимые аргументы}} CLI",
|
|
@@ -54,5 +56,9 @@
|
|
|
54
56
|
"deploy.sourceNotExists": "Не найдено: {source}",
|
|
55
57
|
"deploy.transmitting": "Передача {from} → {to}",
|
|
56
58
|
"deploy.simulationComplete": "Симуляция завершена. Изменения не применены.",
|
|
57
|
-
"deploy.successful": "🎉 Деплой успешно выполнен!"
|
|
59
|
+
"deploy.successful": "🎉 Деплой успешно выполнен!",
|
|
60
|
+
"ssh.hostUntrusted": "Хост {hostname} не является доверенным",
|
|
61
|
+
"ssh.keyType": "Тип ключа: {type}",
|
|
62
|
+
"ssh.fingerprint": "Отпечаток: {fingerprint}",
|
|
63
|
+
"ssh.confirmHostIsTrusted": "Вы доверяете этому хосту?"
|
|
58
64
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mirta/cli",
|
|
3
3
|
"description": "🛠️ Mirta Framework - the CLI",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.6",
|
|
5
5
|
"license": "Unlicense",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"cli",
|
|
@@ -26,7 +26,10 @@
|
|
|
26
26
|
"#src/staged-args": "./src/staged-args/index.js",
|
|
27
27
|
"#src/staged-args/*": "./src/staged-args/*.js",
|
|
28
28
|
"#src/config/connection": "./src/config/connection/index.js",
|
|
29
|
-
"
|
|
29
|
+
"#*": [
|
|
30
|
+
"./src/*.js",
|
|
31
|
+
"./src/*/index.js"
|
|
32
|
+
]
|
|
30
33
|
},
|
|
31
34
|
"homepage": "https://github.com/wb-mirta/core/tree/latest/packages/mirta-cli#readme",
|
|
32
35
|
"repository": {
|
|
@@ -48,12 +51,12 @@
|
|
|
48
51
|
"prompts": "^2.4.2",
|
|
49
52
|
"semver": "^7.7.3",
|
|
50
53
|
"jsonc-parser": "^3.3.1",
|
|
51
|
-
"@mirta/basics": "0.4.
|
|
52
|
-
"@mirta/
|
|
53
|
-
"@mirta/package": "0.4.
|
|
54
|
-
"@mirta/
|
|
55
|
-
"@mirta/staged-args": "0.4.
|
|
56
|
-
"@mirta/workspace": "0.4.
|
|
54
|
+
"@mirta/basics": "0.4.6",
|
|
55
|
+
"@mirta/i18n": "0.4.6",
|
|
56
|
+
"@mirta/package": "0.4.6",
|
|
57
|
+
"@mirta/env-loader": "0.4.6",
|
|
58
|
+
"@mirta/staged-args": "0.4.6",
|
|
59
|
+
"@mirta/workspace": "0.4.6"
|
|
57
60
|
},
|
|
58
61
|
"devDependencies": {
|
|
59
62
|
"@types/semver": "^7.7.1"
|