@iola_adm/iola-cli 0.2.16 → 0.2.18

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
@@ -197,9 +197,9 @@ iola yandex menu
197
197
  iola yandex status
198
198
  ```
199
199
 
200
- Первый контур: Yandex ID, Яндекс Диск и Яндекс Почта. Календарь, контакты, Wiki, Tracker, Forms и документы 360 заложены как категории для проверки и могут потребовать отдельное OAuth-приложение Яндекса. Такси, Маркет и Доставка записаны в backlog только как сценарии подготовки ссылки/маршрута/списка без заказа и оплаты.
200
+ Yandex Connector использует две встроенные OAuth-группы: `IOLA CLI A` для Yandex ID, Диска, Почты и документов через Диск; `IOLA CLI B` для Календаря, Контактов и Телемоста через календарь. Такси, Маркет и Доставка записаны в backlog только как сценарии подготовки ссылки/маршрута/списка без заказа и оплаты.
201
201
 
202
- В `/yandex` функции выбираются номерами через запятую, как в мастере настройки. OAuth-права сами по себе не создают функциональность: под каждый сервис нужны отдельные команды и тулы.
202
+ В `/yandex` функции выбираются номерами через запятую, как в мастере настройки. Там же есть пункт `Удалить подключение-коннектор`, который чистит локальные токены и настройки Yandex Connector. Мастер считает коннектор готовым только после токенов обеих групп. OAuth-права сами по себе не создают функциональность: под каждый сервис нужны отдельные команды и тулы.
203
203
 
204
204
  Инструкция: [Yandex Connector](https://github.com/adm-iola/iola-cli/wiki/Yandex-Connector).
205
205
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
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
@@ -42,8 +42,8 @@ const BROWSER_RUNTIME_PACKAGE = path.join(BROWSER_RUNTIME_DIR, "node_modules", "
42
42
  const CLOUD_DEFAULT_REMOTE_DIR = "/IOLA";
43
43
  const YANDEX_OAUTH_AUTHORIZE_URL = "https://oauth.yandex.ru/authorize";
44
44
  const YANDEX_OAUTH_REDIRECT_URL = "https://oauth.yandex.ru/verification_code";
45
- const YANDEX_CONNECTOR_CLIENT_ID = process.env.IOLA_YANDEX_OAUTH_CLIENT_ID || process.env.YANDEX_OAUTH_CLIENT_ID || "915b0b6ef0474f3a9b3edd70515c5d60";
46
- const YANDEX_CONNECTOR_WORKSPACE_CLIENT_ID = process.env.IOLA_YANDEX_WORKSPACE_OAUTH_CLIENT_ID || "";
45
+ const YANDEX_CONNECTOR_CLIENT_ID = process.env.IOLA_YANDEX_OAUTH_CLIENT_ID || process.env.YANDEX_OAUTH_CLIENT_ID || "9b7c9bcf81e8491f9bd36ba44fa76128";
46
+ const YANDEX_CONNECTOR_ORGANIZER_CLIENT_ID = process.env.IOLA_YANDEX_ORGANIZER_OAUTH_CLIENT_ID || "4ab53d8557e64ac98534ed60295cb138";
47
47
  const YANDEX_CONNECTOR_REDIRECT_HOST = "127.0.0.1";
48
48
  const YANDEX_CONNECTOR_REDIRECT_PORT = Number(process.env.IOLA_YANDEX_OAUTH_PORT || 18791);
49
49
  const YANDEX_CONNECTOR_REDIRECT_PATH = "/yandex/oauth/callback";
@@ -79,30 +79,9 @@ const YANDEX_CONNECTOR_SERVICES = {
79
79
  contacts: {
80
80
  title: "Яндекс Контакты",
81
81
  category: "contacts",
82
- scope: "carddav",
82
+ scope: "addressbook:all",
83
83
  status: "research",
84
- hint: "контакты через CardDAV/360, требует проверки",
85
- },
86
- wiki: {
87
- title: "Yandex Wiki",
88
- category: "workspace",
89
- scope: "wiki:read wiki:write",
90
- status: "research",
91
- hint: "страницы wiki, больше полезно организациям",
92
- },
93
- tracker: {
94
- title: "Yandex Tracker",
95
- category: "workspace",
96
- scope: "tracker:read tracker:write",
97
- status: "research",
98
- hint: "задачи и обращения, больше полезно организациям",
99
- },
100
- forms: {
101
- title: "Yandex Forms",
102
- category: "forms",
103
- scope: "forms:read forms:write",
104
- status: "research",
105
- hint: "формы и опросы, API надо подтвердить",
84
+ hint: "адресная книга и контакты, требует проверки API",
106
85
  },
107
86
  docs: {
108
87
  title: "Яндекс Документы / 360",
@@ -114,9 +93,9 @@ const YANDEX_CONNECTOR_SERVICES = {
114
93
  telemost: {
115
94
  title: "Яндекс Телемост",
116
95
  category: "meetings",
117
- scope: "",
96
+ scope: "calendar:all",
118
97
  status: "research",
119
- hint: "создание встреч через публичный API надо подтвердить",
98
+ hint: "встречи через календарное событие, если поддерживается",
120
99
  },
121
100
  cloud: {
122
101
  title: "Yandex Cloud",
@@ -157,15 +136,15 @@ const YANDEX_CONNECTOR_SERVICES = {
157
136
  const YANDEX_CONNECTOR_OAUTH_APPS = [
158
137
  {
159
138
  id: "core",
160
- title: "IOLA Yandex Core",
139
+ title: "IOLA CLI A",
161
140
  clientId: YANDEX_CONNECTOR_CLIENT_ID,
162
- services: ["identity", "disk", "mail"],
141
+ services: ["identity", "disk", "mail", "docs"],
163
142
  },
164
143
  {
165
- id: "workspace",
166
- title: "IOLA Yandex Workspace",
167
- clientId: YANDEX_CONNECTOR_WORKSPACE_CLIENT_ID,
168
- services: ["contacts", "wiki", "tracker", "forms", "docs"],
144
+ id: "organizer",
145
+ title: "IOLA CLI B",
146
+ clientId: YANDEX_CONNECTOR_ORGANIZER_CLIENT_ID,
147
+ services: ["calendar", "contacts", "telemost"],
169
148
  },
170
149
  ];
171
150
  const INDEXABLE_EXTENSIONS = /\.(md|txt|csv|json|html|docx|xlsx|pptx|pdf)$/i;
@@ -2040,7 +2019,7 @@ async function doctor(args = []) {
2040
2019
  openaiKey: process.env.OPENAI_API_KEY ? "env" : secrets.openai?.apiKey ? "local" : "missing",
2041
2020
  openrouterKey: process.env.OPENROUTER_API_KEY ? "env" : secrets.openrouter?.apiKey ? "local" : "missing",
2042
2021
  yandexGeocoderKey: (process.env.YANDEX_GEOCODER_API_KEY || process.env.YANDEX_MAPS_API_KEY) ? "env" : secrets.yandexGeocoder?.apiKey ? "local" : "missing",
2043
- yandexConnector: (process.env.YANDEX_OAUTH_TOKEN || secrets.yandex?.oauthToken || secrets.cloud?.["yandex-disk"]?.token) ? "local/env" : "missing",
2022
+ yandexConnector: getYandexConnectorSecretStatus(secrets),
2044
2023
  yandexAuthorized: config.yandex?.authorizedServices?.join(", ") || "-",
2045
2024
  yandexServices: config.yandex?.enabledServices?.join(", ") || (secrets.cloud?.["yandex-disk"]?.token ? "disk (legacy cloud token)" : "-"),
2046
2025
  ollama: diagnostics.ollama.installed ? diagnostics.ollama.version : "not-installed",
@@ -3285,7 +3264,7 @@ async function handleYandex(args) {
3285
3264
  iola yandex disable mail
3286
3265
  iola yandex oauth-url [disk mail calendar] [--client-id ID] [--open]
3287
3266
  iola yandex token set
3288
- iola yandex token delete
3267
+ iola yandex token delete удалить локальные токены и настройки коннектора
3289
3268
  iola yandex backlog`);
3290
3269
  }
3291
3270
 
@@ -3343,11 +3322,12 @@ async function setupYandexConnector(args = []) {
3343
3322
  }
3344
3323
  await printYandexConnectorStatus({ check: true });
3345
3324
  } else {
3346
- const app = oauthApps[0] || { clientId, services: authorizedServices };
3347
- const url = buildYandexOAuthUrl({ clientId: app.clientId, services: app.services, redirectUrl });
3348
- console.log("Откройте ссылку авторизации, получите OAuth-токен и сохраните его командой: iola yandex token set");
3349
- console.log(url);
3350
- if (options.open) await openUrl(url);
3325
+ console.log("Откройте ссылки авторизации, получите OAuth-токены и сохраните их командой: iola yandex token set --app APP_ID");
3326
+ for (const app of oauthApps.length ? oauthApps : [{ id: "custom", title: "Yandex Connector", clientId, services: authorizedServices }]) {
3327
+ const url = buildYandexOAuthUrl({ clientId: app.clientId, services: app.services, redirectUrl });
3328
+ console.log(`${app.id}: ${url}`);
3329
+ if (options.open) await openUrl(url);
3330
+ }
3351
3331
  }
3352
3332
  } else {
3353
3333
  console.log("Yandex Connector не может открыть браузер: в этой сборке не задан public OAuth client_id приложения IOLA.");
@@ -3362,7 +3342,8 @@ async function chooseYandexServicesMenu() {
3362
3342
  return;
3363
3343
  }
3364
3344
  const config = await loadConfig();
3365
- const serviceIds = Object.keys(YANDEX_CONNECTOR_SERVICES);
3345
+ const serviceIds = getYandexConnectorMenuServiceIds();
3346
+ const deleteNumber = serviceIds.length + 1;
3366
3347
  const enabled = new Set(config.yandex?.enabledServices?.length ? config.yandex.enabledServices : ["identity", "disk"]);
3367
3348
  const authState = await getYandexServiceAuthState();
3368
3349
  console.log("Функции Яндекса.");
@@ -3374,6 +3355,7 @@ async function chooseYandexServicesMenu() {
3374
3355
  const authLabel = auth?.hasToken ? "подключено" : (auth?.authorized ? "нужен вход" : "нет прав");
3375
3356
  console.log(`${index + 1}. [${marker}] ${service.title} - ${service.hint} (${service.status}, ${authLabel})`);
3376
3357
  });
3358
+ console.log(`${deleteNumber}. Удалить подключение-коннектор`);
3377
3359
  console.log("0. Отмена");
3378
3360
  const defaults = serviceIds.map((id, index) => enabled.has(id) ? String(index + 1) : "").filter(Boolean);
3379
3361
  const answer = (await askText(`Номера через запятую [${defaults.join(",") || "1,2"}]: `)).trim();
@@ -3382,6 +3364,16 @@ async function chooseYandexServicesMenu() {
3382
3364
  return;
3383
3365
  }
3384
3366
  const selectedNumbers = answer ? answer.split(/[,\s]+/).filter(Boolean) : (defaults.length ? defaults : ["1", "2"]);
3367
+ if (selectedNumbers.includes(String(deleteNumber))) {
3368
+ if (selectedNumbers.length > 1) throw new Error("Удаление коннектора выбирается отдельно, без других пунктов.");
3369
+ const ok = await askYesNo("Удалить локальные токены и настройки Yandex Connector? [y/N] ", false);
3370
+ if (!ok) {
3371
+ console.log("Удаление отменено.");
3372
+ return;
3373
+ }
3374
+ await deleteYandexConnectorToken();
3375
+ return;
3376
+ }
3385
3377
  const selected = selectedNumbers.map((item) => {
3386
3378
  const index = Number(item) - 1;
3387
3379
  if (!Number.isInteger(index) || index < 0 || index >= serviceIds.length) {
@@ -3397,6 +3389,12 @@ async function chooseYandexServicesMenu() {
3397
3389
  if (missingToken.length) console.log(`Нужно пройти вход Яндекса для: ${missingToken.join(", ")}. Запустите iola yandex setup.`);
3398
3390
  }
3399
3391
 
3392
+ function getYandexConnectorMenuServiceIds() {
3393
+ return Object.entries(YANDEX_CONNECTOR_SERVICES)
3394
+ .filter(([, service]) => service.status === "ready" || service.status === "research")
3395
+ .map(([id]) => id);
3396
+ }
3397
+
3400
3398
  async function updateYandexEnabledServices(rawServices, enabled) {
3401
3399
  const config = await loadConfig();
3402
3400
  const current = new Set(config.yandex?.enabledServices || []);
@@ -3440,11 +3438,15 @@ async function saveYandexAuthorizedServices(services) {
3440
3438
  async function buildYandexOAuthUrlFromConfig(rawArgs = []) {
3441
3439
  const options = parseOptions(rawArgs);
3442
3440
  const config = await loadConfig();
3443
- const clientId = options["client-id"] || config.yandex?.oauth?.clientId || YANDEX_CONNECTOR_CLIENT_ID;
3444
- if (!clientId) throw new Error("Yandex OAuth Client ID не задан. Пример: iola yandex oauth-url disk --client-id CLIENT_ID");
3441
+ const apps = getConfiguredYandexOAuthApps();
3445
3442
  const capableServices = getYandexOAuthCapableServiceIds();
3446
3443
  const requestedServices = normalizeYandexServiceList(options._.length ? options._ : capableServices);
3447
- const services = requestedServices.filter((id) => capableServices.includes(id));
3444
+ const app = options.app
3445
+ ? apps.find((item) => item.id === options.app)
3446
+ : apps.find((item) => requestedServices.some((service) => item.services.includes(service))) || apps[0];
3447
+ const clientId = options["client-id"] || app?.clientId || config.yandex?.oauth?.clientId || YANDEX_CONNECTOR_CLIENT_ID;
3448
+ if (!clientId) throw new Error("Yandex OAuth Client ID не задан. Пример: iola yandex oauth-url disk --client-id CLIENT_ID");
3449
+ const services = requestedServices.filter((id) => capableServices.includes(id) && (!app || app.services.includes(id)));
3448
3450
  return buildYandexOAuthUrl({ clientId, services, redirectUrl: options["redirect-url"] || config.yandex?.oauth?.redirectUrl || YANDEX_OAUTH_REDIRECT_URL });
3449
3451
  }
3450
3452
 
@@ -3463,7 +3465,16 @@ function getYandexConnectorRedirectUrl() {
3463
3465
  }
3464
3466
 
3465
3467
  async function runYandexBrowserOAuth({ appId = "core", clientId, services, redirectUrl }) {
3466
- 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
+ }
3467
3478
  await setYandexConnectorToken(["--token", token, "--app", appId]);
3468
3479
  console.log("Yandex Connector подключен.");
3469
3480
  }
@@ -3472,10 +3483,28 @@ function waitForYandexOAuthToken({ clientId, services, redirectUrl }) {
3472
3483
  return new Promise((resolvePromise, reject) => {
3473
3484
  let settled = false;
3474
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
+ };
3475
3494
  const server = createServer(async (req, res) => {
3476
3495
  try {
3477
3496
  const url = new URL(req.url || "/", redirectUrl);
3478
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
+ }
3479
3508
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
3480
3509
  res.end(`<!doctype html>
3481
3510
  <html lang="ru"><head><meta charset="utf-8"><title>IOLA Yandex Connector</title></head>
@@ -3483,33 +3512,45 @@ function waitForYandexOAuthToken({ clientId, services, redirectUrl }) {
3483
3512
  <p>Передаю токен в iola-cli...</p>
3484
3513
  <script>
3485
3514
  (async () => {
3486
- const params = new URLSearchParams(location.hash.replace(/^#/, ""));
3515
+ const params = new URLSearchParams(location.hash.replace(/^#/, "") || location.search.replace(/^\\?/, ""));
3487
3516
  const token = params.get("access_token");
3488
3517
  const error = params.get("error") || "";
3489
- await fetch("/yandex/oauth/token", {
3490
- method: "POST",
3491
- headers: { "content-type": "application/json" },
3492
- body: JSON.stringify({ token, error })
3493
- });
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
+ }
3494
3529
  document.body.innerHTML = token
3495
3530
  ? "<p>Yandex Connector подключен. Можно закрыть вкладку и вернуться в терминал.</p>"
3496
3531
  : "<p>Не удалось получить токен. Вернитесь в терминал.</p>";
3532
+ if (!token || error) location.replace("/yandex/oauth/token?" + qs);
3497
3533
  })();
3498
3534
  </script>
3499
3535
  </body></html>`);
3500
3536
  return;
3501
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
+ }
3502
3548
  if (url.pathname === "/yandex/oauth/token" && req.method === "POST") {
3503
3549
  const chunks = [];
3504
3550
  for await (const chunk of req) chunks.push(chunk);
3505
3551
  const payload = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
3506
3552
  if (payload.error) throw new Error(`Yandex OAuth error: ${payload.error}`);
3507
- if (!payload.token) throw new Error("Yandex OAuth token не получен.");
3508
- if (!settled) {
3509
- settled = true;
3510
- clearTimeout(timer);
3511
- resolvePromise(String(payload.token));
3512
- }
3553
+ finish(payload.token);
3513
3554
  res.writeHead(200, { "content-type": "application/json" });
3514
3555
  res.end(JSON.stringify({ ok: true }));
3515
3556
  server.close();
@@ -3557,7 +3598,6 @@ function waitForYandexOAuthToken({ clientId, services, redirectUrl }) {
3557
3598
  function getYandexScopesForServices(services) {
3558
3599
  const scopes = new Set();
3559
3600
  const normalized = normalizeYandexServiceList(services);
3560
- if (normalized.length > 0 && !normalized.includes("identity")) normalized.unshift("identity");
3561
3601
  for (const id of normalized) {
3562
3602
  const raw = YANDEX_CONNECTOR_SERVICES[id]?.scope || "";
3563
3603
  for (const scope of raw.split(/\s+/).filter(Boolean)) scopes.add(scope);
@@ -3581,6 +3621,30 @@ function getYandexOAuthAppById(appId) {
3581
3621
  || null;
3582
3622
  }
3583
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
+
3584
3648
  async function setYandexConnectorToken(args = []) {
3585
3649
  const options = parseOptions(args);
3586
3650
  const appId = options.app || "core";
@@ -3614,7 +3678,18 @@ async function deleteYandexConnectorToken() {
3614
3678
  if (secrets.cloud?.["yandex-disk"]) delete secrets.cloud["yandex-disk"];
3615
3679
  if (secrets.cloud && Object.keys(secrets.cloud).length === 0) delete secrets.cloud;
3616
3680
  await saveSecrets(secrets);
3617
- console.log("Yandex Connector token удален. Токен Яндекс Диска в cloud тоже удален.");
3681
+ await deleteLocalYandexConnectorConfig();
3682
+ console.log("Yandex Connector удален локально. Токены и настройки приложений очищены.");
3683
+ }
3684
+
3685
+ async function deleteLocalYandexConnectorConfig() {
3686
+ const local = await readConfigLayer(CONFIG_FILE);
3687
+ if (!local) return;
3688
+ delete local.yandex;
3689
+ if (local.cloud?.activeProvider === "yandex-disk") local.cloud.activeProvider = "";
3690
+ await mkdir(CONFIG_DIR, { recursive: true });
3691
+ if (existsSync(CONFIG_FILE)) await copyFile(CONFIG_FILE, LAST_GOOD_CONFIG_FILE).catch(() => {});
3692
+ await writeFile(CONFIG_FILE, `${JSON.stringify(local, null, 2)}\n`, "utf8");
3618
3693
  }
3619
3694
 
3620
3695
  async function printYandexConnectorStatus(options = {}) {
@@ -11390,7 +11465,7 @@ async function getOnboardComponentStatus() {
11390
11465
  browser: browser.installed === "yes",
11391
11466
  "yandex-geocoder": Boolean(yandexGeocoderKey),
11392
11467
  cloud: Object.keys(cloudSecrets).length > 0,
11393
- yandex: Boolean(secrets.yandex?.oauthToken || config.yandex?.enabledServices?.length),
11468
+ yandex: isYandexConnectorFullyConnected(secrets),
11394
11469
  };
11395
11470
  }
11396
11471
 
@@ -13415,6 +13490,12 @@ function sanitizeConfig(config) {
13415
13490
  if (Array.isArray(next.skills?.enabled) && next.skills.enabled.includes("local-files") && !next.skills.enabled.includes("personal-docs")) {
13416
13491
  next.skills.enabled = [...next.skills.enabled, "personal-docs"];
13417
13492
  }
13493
+ if (Array.isArray(next.yandex?.enabledServices)) {
13494
+ next.yandex.enabledServices = next.yandex.enabledServices.filter((service) => Boolean(YANDEX_CONNECTOR_SERVICES[service]));
13495
+ }
13496
+ if (Array.isArray(next.yandex?.authorizedServices)) {
13497
+ next.yandex.authorizedServices = next.yandex.authorizedServices.filter((service) => Boolean(YANDEX_CONNECTOR_SERVICES[service]));
13498
+ }
13418
13499
  const localProfile = next.ai?.profiles?.local;
13419
13500
  if (localProfile?.provider === "iola") {
13420
13501
  if (!localProfile.runtime || localProfile.model === "iola-router-1b") {
@@ -60,11 +60,17 @@ assertIncludes(cliSource, "Другая Ollama-модель", "Local model selec
60
60
  assertIncludes(cliSource, "chooseYandexServicesMenu", "Yandex Connector should have a service selection menu");
61
61
  assertIncludes(cliSource, "Функции Яндекса.", "Yandex service selection should use a numbered menu");
62
62
  assertIncludes(cliSource, "Выберите номера функций через запятую", "Yandex service selection should ask for numbers");
63
+ assertIncludes(cliSource, "Удалить подключение-коннектор", "Yandex service selection should allow connector deletion");
63
64
  assertIncludes(cliSource, "getYandexServiceAuthState", "Yandex status should derive permissions from configured OAuth apps");
64
65
  assertIncludes(cliSource, "OAuth-права встроенного приложения", "Yandex setup should report packaged OAuth app permissions");
65
66
  assertIncludes(cliSource, "Выбрать активные функции можно командой /yandex", "Yandex setup should direct service selection to /yandex");
66
67
  assertIncludes(cliSource, "runYandexBrowserOAuth", "Yandex setup should support browser OAuth flow");
67
68
  assertIncludes(cliSource, "IOLA_YANDEX_OAUTH_CLIENT_ID", "Yandex setup should use a packaged/env OAuth client id");
69
+ assertIncludes(cliSource, "IOLA_YANDEX_ORGANIZER_OAUTH_CLIENT_ID", "Yandex setup should support the organizer OAuth app group");
70
+ assertIncludes(cliSource, "addressbook:all", "Yandex contacts should use the addressbook OAuth scope");
71
+ assertIncludes(cliSource, "Автоматический прием OAuth-токена не сработал", "Yandex OAuth should provide manual token fallback");
72
+ assertIncludes(cliSource, "partial (", "Yandex connector status should report partial connections");
73
+ assertIncludes(cliSource, "isYandexConnectorFullyConnected", "Yandex master status should require all OAuth app tokens");
68
74
  assertIncludes(cliSource, "--app", "Yandex token command should persist tokens by OAuth app group");
69
75
  assertNotIncludes(cliSource, "Сервисы через запятую [identity,disk]", "Yandex setup should not ask for services during connector setup");
70
76
  if (!packageJson.files.includes("docs/assets/iola-oauth-icon.png")) {
@@ -21,6 +21,15 @@ iola yandex token set
21
21
  iola yandex token delete
22
22
  ```
23
23
 
24
+ ## OAuth-приложения
25
+
26
+ Для пользователя это один `Yandex Connector`, но внутри CLI проходит две авторизации Яндекса:
27
+
28
+ - `IOLA CLI A` - Yandex ID, Яндекс Диск, Яндекс Почта, Яндекс Документы через Диск;
29
+ - `IOLA CLI B` - Яндекс Календарь, Яндекс Контакты, Телемост через календарное событие.
30
+
31
+ `client_id` этих приложений публичный и встроен в CLI. Пользователь не создает OAuth-приложения вручную: он только входит в свой Яндекс-аккаунт и разрешает доступ. Токены сохраняются локально на компьютере пользователя.
32
+
24
33
  ## Категории
25
34
 
26
35
  Готово к первому контуру:
@@ -33,10 +42,8 @@ iola yandex token delete
33
42
  - `mail` - Яндекс Почта, чтение и поиск писем, отправка только после явного подтверждения;
34
43
  - `calendar` - Яндекс Календарь;
35
44
  - `contacts` - Яндекс Контакты;
36
- - `wiki` - Yandex Wiki;
37
- - `tracker` - Yandex Tracker;
38
- - `forms` - Yandex Forms;
39
- - `docs` - Яндекс Документы / 360.
45
+ - `docs` - Яндекс Документы / 360 через Диск;
46
+ - `telemost` - Яндекс Телемост через календарь, если сценарий подтвердится.
40
47
 
41
48
  Отдельные ключи, не обычный OAuth бытового Яндекса:
42
49
 
@@ -57,7 +64,7 @@ Backlog после первого контура:
57
64
  iola yandex setup
58
65
  ```
59
66
 
60
- 2. Авторизуйтесь в Яндексе и разрешите доступ. В текущем встроенном приложении `IOLA Yandex Core` запрашиваются права:
67
+ 2. Авторизуйтесь в Яндексе и разрешите доступ для `IOLA CLI A`. Запрашиваются права:
61
68
  - `login:info`;
62
69
  - `login:email`;
63
70
  - `cloud_api:disk.read`;
@@ -65,15 +72,19 @@ iola yandex setup
65
72
  - `cloud_api:disk.info`;
66
73
  - `mail:imap_full`;
67
74
  - `mail:smtp`.
68
- 3. После успешной авторизации браузер вернется на локальную страницу `iola-cli`, а CLI сам сохранит OAuth-токен.
69
- 4. Если автоматический браузерный flow недоступен, используйте fallback для разработки:
75
+ 3. Затем CLI откроет вторую авторизацию для `IOLA CLI B`. Запрашиваются права:
76
+ - `calendar:all`;
77
+ - `addressbook:all`.
78
+ 4. После каждой успешной авторизации браузер вернется на локальную страницу `iola-cli`, а CLI сам сохранит OAuth-токен нужной группы.
79
+ Если браузер не смог передать токен обратно в CLI автоматически, CLI попросит вставить `access_token` вручную. Вставлять нужно только значение параметра `access_token`, без всего URL.
80
+ 5. Если автоматический браузерный flow недоступен, используйте fallback для разработки:
70
81
 
71
82
  ```bash
72
83
  iola yandex setup --client-id CLIENT_ID --print-url
73
84
  iola yandex token set
74
85
  ```
75
86
 
76
- 5. Выберите, какие функции CLI реально использует:
87
+ 6. Выберите, какие функции CLI реально использует:
77
88
 
78
89
  ```bash
79
90
  iola yandex menu
@@ -86,8 +97,11 @@ iola yandex menu
86
97
  ```
87
98
 
88
99
  Меню `/yandex` работает как мастер настройки: сервисы выбираются номерами через запятую, а не вводом технических названий.
100
+ В этом же меню есть отдельный пункт `Удалить подключение-коннектор`: он удаляет локальные токены и настройки Yandex Connector, чтобы можно было подключить другой Яндекс-аккаунт или отказаться от сервисов Яндекса.
101
+
102
+ В мастере настройки Yandex Connector считается готовым только после сохранения токенов обеих групп: `IOLA CLI A` и `IOLA CLI B`.
89
103
 
90
- 6. Проверьте:
104
+ 7. Проверьте:
91
105
 
92
106
  ```bash
93
107
  iola yandex doctor
@@ -103,8 +117,10 @@ OAuth-права дают CLI разрешение обращаться к се
103
117
  - `identity` - проверить пользователя и email;
104
118
  - `disk` - папки, загрузка, скачивание, поиск файлов, публичные ссылки;
105
119
  - `mail` - список писем, поиск, чтение письма, отправка письма после явного подтверждения;
106
- - `calendar` - список событий, создание события, напоминания; требует отдельного OAuth-приложения/проверки прав;
107
- - `contacts` - поиск контактов и карточки контактов; требует отдельного OAuth-приложения/проверки прав.
120
+ - `calendar` - список событий, создание события, напоминания;
121
+ - `contacts` - поиск контактов и карточки контактов;
122
+ - `docs` - поиск и работа с документами через Диск;
123
+ - `telemost` - подготовка встречи через календарь, если подтвердится.
108
124
 
109
125
  Включение сервиса в `/yandex` только разрешает CLI использовать соответствующую категорию. Если прав или тула еще нет, CLI должен честно показать это, а не имитировать работу.
110
126
 
@@ -132,6 +148,6 @@ Yandex Connector не является универсальным ключом
132
148
 
133
149
  Для браузерного подключения в сборке CLI уже указан public OAuth `client_id` приложения IOLA. Это не секретный ключ. Пользователь не должен создавать OAuth-приложение вручную.
134
150
 
135
- Яндекс ограничивает количество разных групп сервисов в одном OAuth-приложении. Поэтому один токен не всегда может покрыть Диск, Почту, Календарь, Контакты, Wiki, Tracker и Forms сразу. CLI поддерживает группировку по нескольким OAuth-приложениям; текущий первый контур - `identity`, `disk`, `mail`.
151
+ Яндекс ограничивает количество разных групп сервисов в одном OAuth-приложении. Поэтому один токен не может покрыть Диск, Почту, Календарь и Контакты сразу. CLI использует две встроенные группы: `IOLA CLI A` и `IOLA CLI B`.
136
152
 
137
153
  CLI не должен автоматически оформлять покупки, вызывать такси, подтверждать доставку или выполнять платежи. Для таких сценариев допустима только подготовка ссылки, маршрута или списка, а финальное действие делает пользователь в приложении Яндекса.