@iola_adm/iola-cli 0.2.17 → 0.2.19

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 CHANGED
@@ -199,7 +199,7 @@ iola yandex status
199
199
 
200
200
  Yandex Connector использует две встроенные OAuth-группы: `IOLA CLI A` для Yandex ID, Диска, Почты и документов через Диск; `IOLA CLI B` для Календаря, Контактов и Телемоста через календарь. Такси, Маркет и Доставка записаны в backlog только как сценарии подготовки ссылки/маршрута/списка без заказа и оплаты.
201
201
 
202
- В `/yandex` функции выбираются номерами через запятую, как в мастере настройки. Там же есть пункт `Удалить подключение-коннектор`, который чистит локальные токены и настройки Yandex Connector. OAuth-права сами по себе не создают функциональность: под каждый сервис нужны отдельные команды и тулы.
202
+ В `/yandex` функции выбираются номерами через запятую, как в мастере настройки. Там же есть пункт `Удалить подключение-коннектор`, который чистит локальные токены и настройки Yandex Connector. Мастер считает коннектор готовым только после токенов обеих групп. OAuth-права сами по себе не создают функциональность: под каждый сервис нужны отдельные команды и тулы.
203
203
 
204
204
  Инструкция: [Yandex Connector](https://github.com/adm-iola/iola-cli/wiki/Yandex-Connector).
205
205
 
@@ -32,25 +32,27 @@ const steps = [
32
32
  ];
33
33
 
34
34
  const canAnimate = process.stdout.isTTY && process.env.CI !== "true";
35
+ const setupStarted = process.hrtime.bigint();
35
36
 
36
37
  console.log("");
37
- console.log("IOLA CLI: настройка после установки");
38
+ console.log("IOLA CLI: настройка после установки npm-пакета");
39
+ console.log("Время ниже считает только настройку CLI, без скачивания и распаковки npm-пакета.");
38
40
 
39
41
  for (let index = 0; index < steps.length; index += 1) {
40
42
  const step = steps[index];
41
43
  await runStep(step, index + 1, steps.length);
42
44
  }
43
45
 
44
- console.log("IOLA CLI готова. Запуск: iola");
46
+ console.log(`IOLA CLI готова за ${formatDuration(elapsedMs(setupStarted))}. Запуск: iola`);
45
47
 
46
48
  async function runStep(step, current, total) {
47
- const started = Date.now();
49
+ const started = process.hrtime.bigint();
48
50
  let frame = 0;
49
51
  let lastOutput = "";
50
52
  const prefix = `[${current}/${total}] ${step.title}`;
51
53
  const render = () => {
52
54
  if (!canAnimate) return;
53
- const seconds = Math.max(1, Math.round((Date.now() - started) / 1000));
55
+ const seconds = Math.max(1, Math.floor(elapsedMs(started) / 1000));
54
56
  process.stdout.write(`\r${frames[frame]} ${prefix}... ${seconds}s`);
55
57
  frame = (frame + 1) % frames.length;
56
58
  };
@@ -75,9 +77,9 @@ async function runStep(step, current, total) {
75
77
  }
76
78
 
77
79
  if (canAnimate) {
78
- process.stdout.write(`\r✓ ${prefix} готово за ${formatDuration(Date.now() - started)}\n`);
80
+ process.stdout.write(`\r✓ ${prefix} готово за ${formatDuration(elapsedMs(started))}\n`);
79
81
  } else {
80
- console.log(`✓ ${prefix} готово за ${formatDuration(Date.now() - started)}`);
82
+ console.log(`✓ ${prefix} готово за ${formatDuration(elapsedMs(started))}`);
81
83
  }
82
84
  }
83
85
 
@@ -114,9 +116,15 @@ function run(command, args, onOutput) {
114
116
  });
115
117
  }
116
118
 
119
+ function elapsedMs(started) {
120
+ return Number(process.hrtime.bigint() - started) / 1_000_000;
121
+ }
122
+
117
123
  function formatDuration(ms) {
118
- const seconds = Math.max(1, Math.round(ms / 1000));
119
- if (seconds < 60) return `${seconds}s`;
124
+ if (ms < 1000) return `${Math.max(1, Math.round(ms))}ms`;
125
+ const totalSeconds = ms / 1000;
126
+ if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`;
127
+ const seconds = Math.floor(totalSeconds);
120
128
  const minutes = Math.floor(seconds / 60);
121
129
  const rest = seconds % 60;
122
130
  return `${minutes}m ${rest}s`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
4
4
  "description": "CLI и AI-агент городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
package/src/cli.js CHANGED
@@ -2019,7 +2019,7 @@ async function doctor(args = []) {
2019
2019
  openaiKey: process.env.OPENAI_API_KEY ? "env" : secrets.openai?.apiKey ? "local" : "missing",
2020
2020
  openrouterKey: process.env.OPENROUTER_API_KEY ? "env" : secrets.openrouter?.apiKey ? "local" : "missing",
2021
2021
  yandexGeocoderKey: (process.env.YANDEX_GEOCODER_API_KEY || process.env.YANDEX_MAPS_API_KEY) ? "env" : secrets.yandexGeocoder?.apiKey ? "local" : "missing",
2022
- yandexConnector: (process.env.YANDEX_OAUTH_TOKEN || secrets.yandex?.oauthToken || Object.keys(secrets.yandex?.oauthApps || {}).length || secrets.cloud?.["yandex-disk"]?.token) ? "local/env" : "missing",
2022
+ yandexConnector: getYandexConnectorSecretStatus(secrets),
2023
2023
  yandexAuthorized: config.yandex?.authorizedServices?.join(", ") || "-",
2024
2024
  yandexServices: config.yandex?.enabledServices?.join(", ") || (secrets.cloud?.["yandex-disk"]?.token ? "disk (legacy cloud token)" : "-"),
2025
2025
  ollama: diagnostics.ollama.installed ? diagnostics.ollama.version : "not-installed",
@@ -3465,7 +3465,16 @@ function getYandexConnectorRedirectUrl() {
3465
3465
  }
3466
3466
 
3467
3467
  async function runYandexBrowserOAuth({ appId = "core", clientId, services, redirectUrl }) {
3468
- const token = await waitForYandexOAuthToken({ clientId, services, redirectUrl });
3468
+ let token = "";
3469
+ try {
3470
+ token = await waitForYandexOAuthToken({ clientId, services, redirectUrl });
3471
+ } catch (error) {
3472
+ if (!process.stdin.isTTY || !String(error?.message || error).includes("Время ожидания")) throw error;
3473
+ console.log("Автоматический прием OAuth-токена не сработал.");
3474
+ console.log("Если в адресной строке браузера есть access_token, вставьте только значение access_token.");
3475
+ token = (await askText("Yandex OAuth access_token [Enter - пропустить]: ")).trim();
3476
+ if (!token) throw error;
3477
+ }
3469
3478
  await setYandexConnectorToken(["--token", token, "--app", appId]);
3470
3479
  console.log("Yandex Connector подключен.");
3471
3480
  }
@@ -3474,10 +3483,28 @@ function waitForYandexOAuthToken({ clientId, services, redirectUrl }) {
3474
3483
  return new Promise((resolvePromise, reject) => {
3475
3484
  let settled = false;
3476
3485
  const timeoutMs = 180000;
3486
+ const finish = (token) => {
3487
+ if (!token) throw new Error("Yandex OAuth token не получен.");
3488
+ if (!settled) {
3489
+ settled = true;
3490
+ clearTimeout(timer);
3491
+ resolvePromise(String(token));
3492
+ }
3493
+ };
3477
3494
  const server = createServer(async (req, res) => {
3478
3495
  try {
3479
3496
  const url = new URL(req.url || "/", redirectUrl);
3480
3497
  if (url.pathname === YANDEX_CONNECTOR_REDIRECT_PATH && req.method === "GET") {
3498
+ const tokenFromQuery = url.searchParams.get("access_token") || url.searchParams.get("token");
3499
+ const errorFromQuery = url.searchParams.get("error") || "";
3500
+ if (errorFromQuery) throw new Error(`Yandex OAuth error: ${errorFromQuery}`);
3501
+ if (tokenFromQuery) {
3502
+ finish(tokenFromQuery);
3503
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
3504
+ res.end("<p>Yandex Connector подключен. Можно закрыть вкладку и вернуться в терминал.</p>");
3505
+ server.close();
3506
+ return;
3507
+ }
3481
3508
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
3482
3509
  res.end(`<!doctype html>
3483
3510
  <html lang="ru"><head><meta charset="utf-8"><title>IOLA Yandex Connector</title></head>
@@ -3485,33 +3512,45 @@ function waitForYandexOAuthToken({ clientId, services, redirectUrl }) {
3485
3512
  <p>Передаю токен в iola-cli...</p>
3486
3513
  <script>
3487
3514
  (async () => {
3488
- const params = new URLSearchParams(location.hash.replace(/^#/, ""));
3515
+ const params = new URLSearchParams(location.hash.replace(/^#/, "") || location.search.replace(/^\\?/, ""));
3489
3516
  const token = params.get("access_token");
3490
3517
  const error = params.get("error") || "";
3491
- await fetch("/yandex/oauth/token", {
3492
- method: "POST",
3493
- headers: { "content-type": "application/json" },
3494
- body: JSON.stringify({ token, error })
3495
- });
3518
+ const qs = new URLSearchParams({ token: token || "", error }).toString();
3519
+ try {
3520
+ await fetch("/yandex/oauth/token", {
3521
+ method: "POST",
3522
+ headers: { "content-type": "application/json" },
3523
+ body: JSON.stringify({ token, error })
3524
+ });
3525
+ } catch {
3526
+ location.replace("/yandex/oauth/token?" + qs);
3527
+ return;
3528
+ }
3496
3529
  document.body.innerHTML = token
3497
3530
  ? "<p>Yandex Connector подключен. Можно закрыть вкладку и вернуться в терминал.</p>"
3498
3531
  : "<p>Не удалось получить токен. Вернитесь в терминал.</p>";
3532
+ if (!token || error) location.replace("/yandex/oauth/token?" + qs);
3499
3533
  })();
3500
3534
  </script>
3501
3535
  </body></html>`);
3502
3536
  return;
3503
3537
  }
3538
+ if (url.pathname === "/yandex/oauth/token" && req.method === "GET") {
3539
+ const token = url.searchParams.get("token") || url.searchParams.get("access_token");
3540
+ const error = url.searchParams.get("error") || "";
3541
+ if (error) throw new Error(`Yandex OAuth error: ${error}`);
3542
+ finish(token);
3543
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
3544
+ res.end("<p>Yandex Connector подключен. Можно закрыть вкладку и вернуться в терминал.</p>");
3545
+ server.close();
3546
+ return;
3547
+ }
3504
3548
  if (url.pathname === "/yandex/oauth/token" && req.method === "POST") {
3505
3549
  const chunks = [];
3506
3550
  for await (const chunk of req) chunks.push(chunk);
3507
3551
  const payload = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
3508
3552
  if (payload.error) throw new Error(`Yandex OAuth error: ${payload.error}`);
3509
- if (!payload.token) throw new Error("Yandex OAuth token не получен.");
3510
- if (!settled) {
3511
- settled = true;
3512
- clearTimeout(timer);
3513
- resolvePromise(String(payload.token));
3514
- }
3553
+ finish(payload.token);
3515
3554
  res.writeHead(200, { "content-type": "application/json" });
3516
3555
  res.end(JSON.stringify({ ok: true }));
3517
3556
  server.close();
@@ -3582,6 +3621,30 @@ function getYandexOAuthAppById(appId) {
3582
3621
  || null;
3583
3622
  }
3584
3623
 
3624
+ function getYandexConnectorConnectedAppIds(secrets = {}) {
3625
+ const apps = new Set(Object.keys(secrets.yandex?.oauthApps || {}));
3626
+ if (secrets.yandex?.oauthToken) apps.add("core");
3627
+ if (secrets.cloud?.["yandex-disk"]?.token) apps.add("core");
3628
+ return apps;
3629
+ }
3630
+
3631
+ function isYandexConnectorFullyConnected(secrets = {}) {
3632
+ if (process.env.YANDEX_OAUTH_TOKEN) return true;
3633
+ const connected = getYandexConnectorConnectedAppIds(secrets);
3634
+ const required = getConfiguredYandexOAuthApps().map((app) => app.id);
3635
+ return required.length > 0 && required.every((id) => connected.has(id));
3636
+ }
3637
+
3638
+ function getYandexConnectorSecretStatus(secrets = {}) {
3639
+ if (process.env.YANDEX_OAUTH_TOKEN) return "env";
3640
+ const connected = getYandexConnectorConnectedAppIds(secrets);
3641
+ const required = getConfiguredYandexOAuthApps().map((app) => app.id);
3642
+ if (required.length === 0) return connected.size ? "local" : "missing";
3643
+ if (required.every((id) => connected.has(id))) return "ready";
3644
+ if (connected.size > 0) return `partial (${[...connected].join(", ")})`;
3645
+ return "missing";
3646
+ }
3647
+
3585
3648
  async function setYandexConnectorToken(args = []) {
3586
3649
  const options = parseOptions(args);
3587
3650
  const appId = options.app || "core";
@@ -11402,7 +11465,7 @@ async function getOnboardComponentStatus() {
11402
11465
  browser: browser.installed === "yes",
11403
11466
  "yandex-geocoder": Boolean(yandexGeocoderKey),
11404
11467
  cloud: Object.keys(cloudSecrets).length > 0,
11405
- yandex: Boolean(secrets.yandex?.oauthToken || Object.keys(secrets.yandex?.oauthApps || {}).length || config.yandex?.enabledServices?.length),
11468
+ yandex: isYandexConnectorFullyConnected(secrets),
11406
11469
  };
11407
11470
  }
11408
11471
 
@@ -7,6 +7,7 @@ const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
7
7
  const binPath = resolve(rootDir, "bin", "iola.js");
8
8
  const packageJson = JSON.parse(await readFile(resolve(rootDir, "package.json"), "utf8"));
9
9
  const cliSource = await readFile(resolve(rootDir, "src", "cli.js"), "utf8");
10
+ const postinstallSource = await readFile(resolve(rootDir, "bin", "postinstall.js"), "utf8");
10
11
 
11
12
  function runCli(args) {
12
13
  return new Promise((resolvePromise, reject) => {
@@ -68,11 +69,17 @@ assertIncludes(cliSource, "runYandexBrowserOAuth", "Yandex setup should support
68
69
  assertIncludes(cliSource, "IOLA_YANDEX_OAUTH_CLIENT_ID", "Yandex setup should use a packaged/env OAuth client id");
69
70
  assertIncludes(cliSource, "IOLA_YANDEX_ORGANIZER_OAUTH_CLIENT_ID", "Yandex setup should support the organizer OAuth app group");
70
71
  assertIncludes(cliSource, "addressbook:all", "Yandex contacts should use the addressbook OAuth scope");
72
+ assertIncludes(cliSource, "Автоматический прием OAuth-токена не сработал", "Yandex OAuth should provide manual token fallback");
73
+ assertIncludes(cliSource, "partial (", "Yandex connector status should report partial connections");
74
+ assertIncludes(cliSource, "isYandexConnectorFullyConnected", "Yandex master status should require all OAuth app tokens");
71
75
  assertIncludes(cliSource, "--app", "Yandex token command should persist tokens by OAuth app group");
72
76
  assertNotIncludes(cliSource, "Сервисы через запятую [identity,disk]", "Yandex setup should not ask for services during connector setup");
73
77
  if (!packageJson.files.includes("docs/assets/iola-oauth-icon.png")) {
74
78
  throw new Error("package files should include the Yandex OAuth icon");
75
79
  }
80
+ assertIncludes(postinstallSource, "process.hrtime.bigint()", "postinstall should use a monotonic timer");
81
+ assertIncludes(postinstallSource, "без скачивания и распаковки npm-пакета", "postinstall timing should not imply full npm install time");
82
+ assertIncludes(postinstallSource, "IOLA CLI готова за", "postinstall should print total setup duration");
76
83
 
77
84
  const commands = await runCli(["commands"]);
78
85
  assertIncludes(commands, "iola browser status|install|open|text|html|screenshot|pdf|click|type|eval", "commands");
@@ -76,6 +76,7 @@ iola yandex setup
76
76
  - `calendar:all`;
77
77
  - `addressbook:all`.
78
78
  4. После каждой успешной авторизации браузер вернется на локальную страницу `iola-cli`, а CLI сам сохранит OAuth-токен нужной группы.
79
+ Если браузер не смог передать токен обратно в CLI автоматически, CLI попросит вставить `access_token` вручную. Вставлять нужно только значение параметра `access_token`, без всего URL.
79
80
  5. Если автоматический браузерный flow недоступен, используйте fallback для разработки:
80
81
 
81
82
  ```bash
@@ -98,6 +99,8 @@ iola yandex menu
98
99
  Меню `/yandex` работает как мастер настройки: сервисы выбираются номерами через запятую, а не вводом технических названий.
99
100
  В этом же меню есть отдельный пункт `Удалить подключение-коннектор`: он удаляет локальные токены и настройки Yandex Connector, чтобы можно было подключить другой Яндекс-аккаунт или отказаться от сервисов Яндекса.
100
101
 
102
+ В мастере настройки Yandex Connector считается готовым только после сохранения токенов обеих групп: `IOLA CLI A` и `IOLA CLI B`.
103
+
101
104
  7. Проверьте:
102
105
 
103
106
  ```bash