@mirta/cli 0.4.3 → 0.4.5

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 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 };
@@ -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
  }
@@ -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.3",
4
+ "version": "0.4.5",
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
- "#utils/*": "./src/utils/*.js"
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/env-loader": "0.4.3",
52
- "@mirta/basics": "0.4.3",
53
- "@mirta/i18n": "0.4.3",
54
- "@mirta/staged-args": "0.4.3",
55
- "@mirta/package": "0.4.3",
56
- "@mirta/workspace": "0.4.3"
54
+ "@mirta/env-loader": "0.4.5",
55
+ "@mirta/package": "0.4.5",
56
+ "@mirta/i18n": "0.4.5",
57
+ "@mirta/basics": "0.4.5",
58
+ "@mirta/staged-args": "0.4.5",
59
+ "@mirta/workspace": "0.4.5"
57
60
  },
58
61
  "devDependencies": {
59
62
  "@types/semver": "^7.7.1"