@iola_adm/iola-cli 0.2.14 → 0.2.16
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 +4 -2
- package/bin/postinstall.js +29 -4
- package/docs/assets/iola-oauth-icon.png +0 -0
- package/package.json +2 -1
- package/src/cli.js +218 -37
- package/test/smoke-test.js +10 -1
- package/wiki/Yandex-Connector.md +50 -15
package/README.md
CHANGED
|
@@ -188,7 +188,7 @@ iola cloud backup
|
|
|
188
188
|
|
|
189
189
|
Инструкция: [Облачные диски](https://github.com/adm-iola/iola-cli/wiki/Облачные-диски).
|
|
190
190
|
|
|
191
|
-
Yandex Connector
|
|
191
|
+
Yandex Connector открывает браузер, авторизует пользователя в Яндексе и сохраняет OAuth-токен локально. Какие функции CLI реально использует, выбирается отдельно:
|
|
192
192
|
|
|
193
193
|
```bash
|
|
194
194
|
iola yandex services
|
|
@@ -197,7 +197,9 @@ iola yandex menu
|
|
|
197
197
|
iola yandex status
|
|
198
198
|
```
|
|
199
199
|
|
|
200
|
-
Первый контур: Yandex ID и Яндекс
|
|
200
|
+
Первый контур: Yandex ID, Яндекс Диск и Яндекс Почта. Календарь, контакты, Wiki, Tracker, Forms и документы 360 заложены как категории для проверки и могут потребовать отдельное OAuth-приложение Яндекса. Такси, Маркет и Доставка записаны в backlog только как сценарии подготовки ссылки/маршрута/списка без заказа и оплаты.
|
|
201
|
+
|
|
202
|
+
В `/yandex` функции выбираются номерами через запятую, как в мастере настройки. OAuth-права сами по себе не создают функциональность: под каждый сервис нужны отдельные команды и тулы.
|
|
201
203
|
|
|
202
204
|
Инструкция: [Yandex Connector](https://github.com/adm-iola/iola-cli/wiki/Yandex-Connector).
|
|
203
205
|
|
package/bin/postinstall.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import {
|
|
4
|
+
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
5
7
|
|
|
6
8
|
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
7
9
|
const cliPath = resolve(rootDir, "bin", "iola.js");
|
|
10
|
+
const oauthIconSource = resolve(rootDir, "docs", "assets", "iola-oauth-icon.png");
|
|
11
|
+
const oauthIconTarget = join(os.homedir(), ".iola", "assets", "iola-oauth-icon.png");
|
|
8
12
|
const node = process.execPath;
|
|
9
13
|
const frames = ["|", "/", "-", "\\"];
|
|
10
14
|
|
|
@@ -21,6 +25,10 @@ const steps = [
|
|
|
21
25
|
title: "Проверка локальной модели IOLA",
|
|
22
26
|
args: [cliPath, "ai", "setup", "iola", "--yes", "--quiet", "--optional", "--preserve-active"],
|
|
23
27
|
},
|
|
28
|
+
{
|
|
29
|
+
title: "Установка иконки Yandex OAuth",
|
|
30
|
+
local: installOauthIcon,
|
|
31
|
+
},
|
|
24
32
|
];
|
|
25
33
|
|
|
26
34
|
const canAnimate = process.stdout.isTTY && process.env.CI !== "true";
|
|
@@ -52,9 +60,11 @@ async function runStep(step, current, total) {
|
|
|
52
60
|
}
|
|
53
61
|
render();
|
|
54
62
|
const timer = setInterval(render, 120);
|
|
55
|
-
const result =
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
const result = step.local
|
|
64
|
+
? await runLocalStep(step.local)
|
|
65
|
+
: await run(node, ["--no-warnings", ...step.args], (chunk) => {
|
|
66
|
+
lastOutput = chunk.trim() || lastOutput;
|
|
67
|
+
});
|
|
58
68
|
clearInterval(timer);
|
|
59
69
|
|
|
60
70
|
if (result.code !== 0) {
|
|
@@ -71,6 +81,21 @@ async function runStep(step, current, total) {
|
|
|
71
81
|
}
|
|
72
82
|
}
|
|
73
83
|
|
|
84
|
+
async function runLocalStep(fn) {
|
|
85
|
+
try {
|
|
86
|
+
await fn();
|
|
87
|
+
return { code: 0 };
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return { code: 1, error };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function installOauthIcon() {
|
|
94
|
+
if (!existsSync(oauthIconSource)) return;
|
|
95
|
+
mkdirSync(dirname(oauthIconTarget), { recursive: true });
|
|
96
|
+
copyFileSync(oauthIconSource, oauthIconTarget);
|
|
97
|
+
}
|
|
98
|
+
|
|
74
99
|
function run(command, args, onOutput) {
|
|
75
100
|
return new Promise((resolvePromise) => {
|
|
76
101
|
const child = spawn(command, args, {
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iola_adm/iola-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.16",
|
|
4
4
|
"description": "CLI и AI-агент городского округа Йошкар-Ола.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/adm-iola/iola-cli#readme",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"skills",
|
|
28
28
|
"wiki",
|
|
29
29
|
"docs/assets/readme-header.png",
|
|
30
|
+
"docs/assets/iola-oauth-icon.png",
|
|
30
31
|
"README.md",
|
|
31
32
|
"LICENSE"
|
|
32
33
|
],
|
package/src/cli.js
CHANGED
|
@@ -42,6 +42,11 @@ 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 || "";
|
|
47
|
+
const YANDEX_CONNECTOR_REDIRECT_HOST = "127.0.0.1";
|
|
48
|
+
const YANDEX_CONNECTOR_REDIRECT_PORT = Number(process.env.IOLA_YANDEX_OAUTH_PORT || 18791);
|
|
49
|
+
const YANDEX_CONNECTOR_REDIRECT_PATH = "/yandex/oauth/callback";
|
|
45
50
|
const YANDEX_CONNECTOR_SERVICES = {
|
|
46
51
|
identity: {
|
|
47
52
|
title: "Yandex ID",
|
|
@@ -149,6 +154,20 @@ const YANDEX_CONNECTOR_SERVICES = {
|
|
|
149
154
|
hint: "только подготовка заявки/ссылки, без оформления и оплаты",
|
|
150
155
|
},
|
|
151
156
|
};
|
|
157
|
+
const YANDEX_CONNECTOR_OAUTH_APPS = [
|
|
158
|
+
{
|
|
159
|
+
id: "core",
|
|
160
|
+
title: "IOLA Yandex Core",
|
|
161
|
+
clientId: YANDEX_CONNECTOR_CLIENT_ID,
|
|
162
|
+
services: ["identity", "disk", "mail"],
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: "workspace",
|
|
166
|
+
title: "IOLA Yandex Workspace",
|
|
167
|
+
clientId: YANDEX_CONNECTOR_WORKSPACE_CLIENT_ID,
|
|
168
|
+
services: ["contacts", "wiki", "tracker", "forms", "docs"],
|
|
169
|
+
},
|
|
170
|
+
];
|
|
152
171
|
const INDEXABLE_EXTENSIONS = /\.(md|txt|csv|json|html|docx|xlsx|pptx|pdf)$/i;
|
|
153
172
|
const LOCAL_TOOLS = ["search_data", "search_entities", "resolve_entity_field", "get_card", "export_report", "file_read", "browser_open", "get_current_date"];
|
|
154
173
|
const LEGACY_LOCAL_TOOLS = ["search_local", "export_data", "run_report", "save_view"];
|
|
@@ -3294,32 +3313,46 @@ function printYandexServices(options = {}) {
|
|
|
3294
3313
|
async function setupYandexConnector(args = []) {
|
|
3295
3314
|
const options = parseOptions(args);
|
|
3296
3315
|
const config = await loadConfig();
|
|
3316
|
+
const oauthApps = getConfiguredYandexOAuthApps();
|
|
3297
3317
|
const authorizedServices = getYandexOAuthCapableServiceIds();
|
|
3298
3318
|
const enabledServices = config.yandex?.enabledServices?.length ? config.yandex.enabledServices : ["identity", "disk"];
|
|
3299
3319
|
await saveYandexAuthorizedServices(authorizedServices);
|
|
3300
3320
|
await saveYandexEnabledServices(enabledServices);
|
|
3301
3321
|
|
|
3302
|
-
const clientId = options["client-id"] || config.yandex?.oauth?.clientId ||
|
|
3322
|
+
const clientId = options["client-id"] || config.yandex?.oauth?.clientId || oauthApps[0]?.clientId || "";
|
|
3323
|
+
const redirectUrl = options["redirect-url"] || getYandexConnectorRedirectUrl();
|
|
3303
3324
|
if (clientId) {
|
|
3304
3325
|
await saveConfig({
|
|
3305
3326
|
yandex: {
|
|
3306
3327
|
...(config.yandex || {}),
|
|
3307
|
-
oauth: { ...(config.yandex?.oauth || {}), clientId, redirectUrl
|
|
3328
|
+
oauth: { ...(config.yandex?.oauth || {}), clientId, redirectUrl },
|
|
3308
3329
|
},
|
|
3309
3330
|
});
|
|
3310
3331
|
}
|
|
3311
3332
|
|
|
3312
3333
|
console.log("Yandex Connector настроен.");
|
|
3313
|
-
console.log(
|
|
3334
|
+
console.log(`OAuth-права встроенного приложения: ${authorizedServices.join(", ")}`);
|
|
3314
3335
|
console.log(`Активные функции CLI: ${normalizeYandexServiceList(enabledServices).join(", ")}`);
|
|
3315
3336
|
console.log("Выбрать активные функции можно командой /yandex или iola yandex menu.");
|
|
3316
|
-
if (clientId) {
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3337
|
+
if (clientId || oauthApps.length) {
|
|
3338
|
+
if (process.stdin.isTTY && !options["print-url"]) {
|
|
3339
|
+
console.log("Открываю браузер для входа в Яндекс. После авторизации токен сохранится автоматически.");
|
|
3340
|
+
for (const app of oauthApps.length ? oauthApps : [{ id: "custom", title: "Yandex Connector", clientId, services: authorizedServices }]) {
|
|
3341
|
+
console.log(`Авторизация: ${app.title}`);
|
|
3342
|
+
await runYandexBrowserOAuth({ appId: app.id, clientId: app.clientId, services: app.services, redirectUrl });
|
|
3343
|
+
}
|
|
3344
|
+
await printYandexConnectorStatus({ check: true });
|
|
3345
|
+
} 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);
|
|
3351
|
+
}
|
|
3321
3352
|
} else {
|
|
3322
|
-
console.log("
|
|
3353
|
+
console.log("Yandex Connector не может открыть браузер: в этой сборке не задан public OAuth client_id приложения IOLA.");
|
|
3354
|
+
console.log("Нужно один раз зарегистрировать OAuth-приложение IOLA и задать IOLA_YANDEX_OAUTH_CLIENT_ID при сборке/запуске CLI.");
|
|
3355
|
+
console.log("Ручной fallback для разработки: iola yandex setup --client-id CLIENT_ID");
|
|
3323
3356
|
}
|
|
3324
3357
|
}
|
|
3325
3358
|
|
|
@@ -3331,11 +3364,15 @@ async function chooseYandexServicesMenu() {
|
|
|
3331
3364
|
const config = await loadConfig();
|
|
3332
3365
|
const serviceIds = Object.keys(YANDEX_CONNECTOR_SERVICES);
|
|
3333
3366
|
const enabled = new Set(config.yandex?.enabledServices?.length ? config.yandex.enabledServices : ["identity", "disk"]);
|
|
3334
|
-
|
|
3367
|
+
const authState = await getYandexServiceAuthState();
|
|
3368
|
+
console.log("Функции Яндекса.");
|
|
3369
|
+
console.log("Выберите номера функций через запятую:");
|
|
3335
3370
|
serviceIds.forEach((id, index) => {
|
|
3336
3371
|
const service = YANDEX_CONNECTOR_SERVICES[id];
|
|
3337
3372
|
const marker = enabled.has(id) ? "✓" : " ";
|
|
3338
|
-
|
|
3373
|
+
const auth = authState.byService[id];
|
|
3374
|
+
const authLabel = auth?.hasToken ? "подключено" : (auth?.authorized ? "нужен вход" : "нет прав");
|
|
3375
|
+
console.log(`${index + 1}. [${marker}] ${service.title} - ${service.hint} (${service.status}, ${authLabel})`);
|
|
3339
3376
|
});
|
|
3340
3377
|
console.log("0. Отмена");
|
|
3341
3378
|
const defaults = serviceIds.map((id, index) => enabled.has(id) ? String(index + 1) : "").filter(Boolean);
|
|
@@ -3354,13 +3391,10 @@ async function chooseYandexServicesMenu() {
|
|
|
3354
3391
|
});
|
|
3355
3392
|
await saveYandexEnabledServices(selected);
|
|
3356
3393
|
console.log(`Включены сервисы: ${normalizeYandexServiceList(selected).join(", ")}`);
|
|
3357
|
-
const
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
} else {
|
|
3362
|
-
console.log("Для авторизации создайте OAuth Client ID и выполните: iola yandex oauth-url --client-id CLIENT_ID");
|
|
3363
|
-
}
|
|
3394
|
+
const missingAuth = selected.filter((id) => !authState.byService[id]?.authorized);
|
|
3395
|
+
const missingToken = selected.filter((id) => authState.byService[id]?.authorized && !authState.byService[id]?.hasToken);
|
|
3396
|
+
if (missingAuth.length) console.log(`Нет OAuth-прав в текущей сборке: ${missingAuth.join(", ")}. Для них нужно отдельное OAuth-приложение Яндекса.`);
|
|
3397
|
+
if (missingToken.length) console.log(`Нужно пройти вход Яндекса для: ${missingToken.join(", ")}. Запустите iola yandex setup.`);
|
|
3364
3398
|
}
|
|
3365
3399
|
|
|
3366
3400
|
async function updateYandexEnabledServices(rawServices, enabled) {
|
|
@@ -3406,22 +3440,120 @@ async function saveYandexAuthorizedServices(services) {
|
|
|
3406
3440
|
async function buildYandexOAuthUrlFromConfig(rawArgs = []) {
|
|
3407
3441
|
const options = parseOptions(rawArgs);
|
|
3408
3442
|
const config = await loadConfig();
|
|
3409
|
-
const clientId = options["client-id"] || config.yandex?.oauth?.clientId;
|
|
3443
|
+
const clientId = options["client-id"] || config.yandex?.oauth?.clientId || YANDEX_CONNECTOR_CLIENT_ID;
|
|
3410
3444
|
if (!clientId) throw new Error("Yandex OAuth Client ID не задан. Пример: iola yandex oauth-url disk --client-id CLIENT_ID");
|
|
3411
|
-
const
|
|
3412
|
-
|
|
3445
|
+
const capableServices = getYandexOAuthCapableServiceIds();
|
|
3446
|
+
const requestedServices = normalizeYandexServiceList(options._.length ? options._ : capableServices);
|
|
3447
|
+
const services = requestedServices.filter((id) => capableServices.includes(id));
|
|
3448
|
+
return buildYandexOAuthUrl({ clientId, services, redirectUrl: options["redirect-url"] || config.yandex?.oauth?.redirectUrl || YANDEX_OAUTH_REDIRECT_URL });
|
|
3413
3449
|
}
|
|
3414
3450
|
|
|
3415
|
-
function buildYandexOAuthUrl({ clientId, services }) {
|
|
3451
|
+
function buildYandexOAuthUrl({ clientId, services, redirectUrl = YANDEX_OAUTH_REDIRECT_URL }) {
|
|
3416
3452
|
const scopes = getYandexScopesForServices(services);
|
|
3417
3453
|
const url = new URL(YANDEX_OAUTH_AUTHORIZE_URL);
|
|
3418
3454
|
url.searchParams.set("response_type", "token");
|
|
3419
3455
|
url.searchParams.set("client_id", clientId);
|
|
3420
|
-
url.searchParams.set("redirect_uri",
|
|
3456
|
+
url.searchParams.set("redirect_uri", redirectUrl);
|
|
3421
3457
|
if (scopes) url.searchParams.set("scope", scopes);
|
|
3422
3458
|
return url.toString();
|
|
3423
3459
|
}
|
|
3424
3460
|
|
|
3461
|
+
function getYandexConnectorRedirectUrl() {
|
|
3462
|
+
return `http://${YANDEX_CONNECTOR_REDIRECT_HOST}:${YANDEX_CONNECTOR_REDIRECT_PORT}${YANDEX_CONNECTOR_REDIRECT_PATH}`;
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
async function runYandexBrowserOAuth({ appId = "core", clientId, services, redirectUrl }) {
|
|
3466
|
+
const token = await waitForYandexOAuthToken({ clientId, services, redirectUrl });
|
|
3467
|
+
await setYandexConnectorToken(["--token", token, "--app", appId]);
|
|
3468
|
+
console.log("Yandex Connector подключен.");
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
function waitForYandexOAuthToken({ clientId, services, redirectUrl }) {
|
|
3472
|
+
return new Promise((resolvePromise, reject) => {
|
|
3473
|
+
let settled = false;
|
|
3474
|
+
const timeoutMs = 180000;
|
|
3475
|
+
const server = createServer(async (req, res) => {
|
|
3476
|
+
try {
|
|
3477
|
+
const url = new URL(req.url || "/", redirectUrl);
|
|
3478
|
+
if (url.pathname === YANDEX_CONNECTOR_REDIRECT_PATH && req.method === "GET") {
|
|
3479
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
3480
|
+
res.end(`<!doctype html>
|
|
3481
|
+
<html lang="ru"><head><meta charset="utf-8"><title>IOLA Yandex Connector</title></head>
|
|
3482
|
+
<body>
|
|
3483
|
+
<p>Передаю токен в iola-cli...</p>
|
|
3484
|
+
<script>
|
|
3485
|
+
(async () => {
|
|
3486
|
+
const params = new URLSearchParams(location.hash.replace(/^#/, ""));
|
|
3487
|
+
const token = params.get("access_token");
|
|
3488
|
+
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
|
+
});
|
|
3494
|
+
document.body.innerHTML = token
|
|
3495
|
+
? "<p>Yandex Connector подключен. Можно закрыть вкладку и вернуться в терминал.</p>"
|
|
3496
|
+
: "<p>Не удалось получить токен. Вернитесь в терминал.</p>";
|
|
3497
|
+
})();
|
|
3498
|
+
</script>
|
|
3499
|
+
</body></html>`);
|
|
3500
|
+
return;
|
|
3501
|
+
}
|
|
3502
|
+
if (url.pathname === "/yandex/oauth/token" && req.method === "POST") {
|
|
3503
|
+
const chunks = [];
|
|
3504
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
3505
|
+
const payload = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
3506
|
+
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
|
+
}
|
|
3513
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
3514
|
+
res.end(JSON.stringify({ ok: true }));
|
|
3515
|
+
server.close();
|
|
3516
|
+
return;
|
|
3517
|
+
}
|
|
3518
|
+
res.writeHead(404);
|
|
3519
|
+
res.end("not found");
|
|
3520
|
+
} catch (error) {
|
|
3521
|
+
if (!settled) {
|
|
3522
|
+
settled = true;
|
|
3523
|
+
clearTimeout(timer);
|
|
3524
|
+
reject(error);
|
|
3525
|
+
}
|
|
3526
|
+
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
3527
|
+
res.end(error instanceof Error ? error.message : String(error));
|
|
3528
|
+
server.close();
|
|
3529
|
+
}
|
|
3530
|
+
});
|
|
3531
|
+
const timer = setTimeout(() => {
|
|
3532
|
+
if (!settled) {
|
|
3533
|
+
settled = true;
|
|
3534
|
+
server.close();
|
|
3535
|
+
reject(new Error("Время ожидания авторизации Яндекса истекло."));
|
|
3536
|
+
}
|
|
3537
|
+
}, timeoutMs);
|
|
3538
|
+
server.on("error", (error) => {
|
|
3539
|
+
clearTimeout(timer);
|
|
3540
|
+
if (!settled) {
|
|
3541
|
+
settled = true;
|
|
3542
|
+
reject(error);
|
|
3543
|
+
}
|
|
3544
|
+
});
|
|
3545
|
+
server.listen(YANDEX_CONNECTOR_REDIRECT_PORT, YANDEX_CONNECTOR_REDIRECT_HOST, async () => {
|
|
3546
|
+
const authUrl = buildYandexOAuthUrl({ clientId, services, redirectUrl });
|
|
3547
|
+
console.log(`Если браузер не открылся, откройте ссылку вручную: ${authUrl}`);
|
|
3548
|
+
try {
|
|
3549
|
+
await openUrl(authUrl);
|
|
3550
|
+
} catch (error) {
|
|
3551
|
+
console.log(`Не удалось открыть браузер автоматически: ${error instanceof Error ? error.message : String(error)}`);
|
|
3552
|
+
}
|
|
3553
|
+
});
|
|
3554
|
+
});
|
|
3555
|
+
}
|
|
3556
|
+
|
|
3425
3557
|
function getYandexScopesForServices(services) {
|
|
3426
3558
|
const scopes = new Set();
|
|
3427
3559
|
const normalized = normalizeYandexServiceList(services);
|
|
@@ -3434,26 +3566,46 @@ function getYandexScopesForServices(services) {
|
|
|
3434
3566
|
}
|
|
3435
3567
|
|
|
3436
3568
|
function getYandexOAuthCapableServiceIds() {
|
|
3437
|
-
return
|
|
3438
|
-
|
|
3439
|
-
|
|
3569
|
+
return [...new Set(getConfiguredYandexOAuthApps().flatMap((app) => app.services))];
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
function getConfiguredYandexOAuthApps() {
|
|
3573
|
+
return YANDEX_CONNECTOR_OAUTH_APPS
|
|
3574
|
+
.filter((app) => app.clientId)
|
|
3575
|
+
.map((app) => ({ ...app, services: normalizeYandexServiceList(app.services) }));
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
function getYandexOAuthAppById(appId) {
|
|
3579
|
+
return getConfiguredYandexOAuthApps().find((app) => app.id === appId)
|
|
3580
|
+
|| YANDEX_CONNECTOR_OAUTH_APPS.find((app) => app.id === appId)
|
|
3581
|
+
|| null;
|
|
3440
3582
|
}
|
|
3441
3583
|
|
|
3442
3584
|
async function setYandexConnectorToken(args = []) {
|
|
3443
3585
|
const options = parseOptions(args);
|
|
3586
|
+
const appId = options.app || "core";
|
|
3444
3587
|
const token = options.token || (process.stdin.isTTY ? (await askText("Yandex OAuth token: ")).trim() : "");
|
|
3445
3588
|
if (!token) throw new Error("OAuth token обязателен.");
|
|
3589
|
+
const app = getYandexOAuthAppById(appId);
|
|
3590
|
+
const appServices = normalizeYandexServiceList(app?.services || []);
|
|
3591
|
+
const hasDiskAccess = appServices.includes("disk") || appId === "core";
|
|
3446
3592
|
const secrets = await loadSecrets();
|
|
3447
3593
|
secrets.yandex = secrets.yandex || {};
|
|
3448
|
-
secrets.yandex.
|
|
3594
|
+
secrets.yandex.oauthApps = secrets.yandex.oauthApps || {};
|
|
3595
|
+
secrets.yandex.oauthApps[appId] = { token, updatedAt: new Date().toISOString() };
|
|
3596
|
+
if (appId === "core") secrets.yandex.oauthToken = token;
|
|
3449
3597
|
secrets.yandex.updatedAt = new Date().toISOString();
|
|
3450
|
-
|
|
3451
|
-
|
|
3598
|
+
if (hasDiskAccess) {
|
|
3599
|
+
secrets.cloud = secrets.cloud || {};
|
|
3600
|
+
secrets.cloud["yandex-disk"] = { token };
|
|
3601
|
+
}
|
|
3452
3602
|
await saveSecrets(secrets);
|
|
3453
|
-
|
|
3454
|
-
|
|
3603
|
+
if (hasDiskAccess) {
|
|
3604
|
+
const config = await loadConfig();
|
|
3605
|
+
await saveConfig({ cloud: { ...(config.cloud || {}), activeProvider: "yandex-disk" } });
|
|
3606
|
+
}
|
|
3455
3607
|
console.log(`Yandex OAuth token сохранен локально: ${SECRETS_FILE}`);
|
|
3456
|
-
console.log("Токен также подключен к cloud provider yandex-disk.");
|
|
3608
|
+
if (hasDiskAccess) console.log("Токен также подключен к cloud provider yandex-disk.");
|
|
3457
3609
|
}
|
|
3458
3610
|
|
|
3459
3611
|
async function deleteYandexConnectorToken() {
|
|
@@ -3468,16 +3620,16 @@ async function deleteYandexConnectorToken() {
|
|
|
3468
3620
|
async function printYandexConnectorStatus(options = {}) {
|
|
3469
3621
|
const [config, secrets] = await Promise.all([loadConfig(), loadSecrets()]);
|
|
3470
3622
|
const enabled = config.yandex?.enabledServices || [];
|
|
3471
|
-
const authorized = config.yandex?.authorizedServices?.length ? config.yandex.authorizedServices : [];
|
|
3472
3623
|
const legacyDiskToken = Boolean(secrets.cloud?.["yandex-disk"]?.token && !secrets.yandex?.oauthToken);
|
|
3624
|
+
const authState = await getYandexServiceAuthState({ config, secrets });
|
|
3473
3625
|
const token = process.env.YANDEX_OAUTH_TOKEN || secrets.yandex?.oauthToken || secrets.cloud?.["yandex-disk"]?.token || "";
|
|
3474
3626
|
const rows = Object.entries(YANDEX_CONNECTOR_SERVICES).map(([id, service]) => ({
|
|
3475
3627
|
id,
|
|
3476
3628
|
enabled: enabled.includes(id) ? "yes" : (legacyDiskToken && id === "disk" ? "legacy" : "no"),
|
|
3477
3629
|
category: service.category,
|
|
3478
3630
|
status: service.status,
|
|
3479
|
-
token: service.scope &&
|
|
3480
|
-
authorized:
|
|
3631
|
+
token: service.scope && authState.byService[id]?.authorized ? (authState.byService[id]?.hasToken ? "local/env" : "missing") : "-",
|
|
3632
|
+
authorized: authState.byService[id]?.authorized ? "yes" : "-",
|
|
3481
3633
|
title: service.title,
|
|
3482
3634
|
}));
|
|
3483
3635
|
printTable(rows, [
|
|
@@ -3501,6 +3653,35 @@ async function printYandexConnectorStatus(options = {}) {
|
|
|
3501
3653
|
}
|
|
3502
3654
|
}
|
|
3503
3655
|
|
|
3656
|
+
async function getYandexServiceAuthState({ config = null, secrets = null } = {}) {
|
|
3657
|
+
const loadedConfig = config || await loadConfig();
|
|
3658
|
+
const loadedSecrets = secrets || await loadSecrets();
|
|
3659
|
+
const apps = getConfiguredYandexOAuthApps();
|
|
3660
|
+
const appTokens = loadedSecrets.yandex?.oauthApps || {};
|
|
3661
|
+
const envToken = process.env.YANDEX_OAUTH_TOKEN || "";
|
|
3662
|
+
const byService = {};
|
|
3663
|
+
for (const id of Object.keys(YANDEX_CONNECTOR_SERVICES)) {
|
|
3664
|
+
byService[id] = { authorized: false, hasToken: false, apps: [] };
|
|
3665
|
+
}
|
|
3666
|
+
for (const app of apps) {
|
|
3667
|
+
const hasToken = Boolean(envToken || appTokens[app.id]?.token || (app.id === "core" && loadedSecrets.yandex?.oauthToken));
|
|
3668
|
+
for (const id of normalizeYandexServiceList(app.services)) {
|
|
3669
|
+
byService[id] = byService[id] || { authorized: false, hasToken: false, apps: [] };
|
|
3670
|
+
byService[id].authorized = true;
|
|
3671
|
+
byService[id].hasToken = byService[id].hasToken || hasToken;
|
|
3672
|
+
byService[id].apps.push(app.id);
|
|
3673
|
+
}
|
|
3674
|
+
}
|
|
3675
|
+
if (loadedSecrets.cloud?.["yandex-disk"]?.token) {
|
|
3676
|
+
byService.disk.authorized = true;
|
|
3677
|
+
byService.disk.hasToken = true;
|
|
3678
|
+
byService.disk.apps.push("legacy-cloud");
|
|
3679
|
+
}
|
|
3680
|
+
const authorizedServices = Object.entries(byService).filter(([, state]) => state.authorized).map(([id]) => id);
|
|
3681
|
+
const enabledServices = loadedConfig.yandex?.enabledServices || [];
|
|
3682
|
+
return { byService, authorizedServices, enabledServices };
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3504
3685
|
async function yandexUserInfo(token) {
|
|
3505
3686
|
const response = await fetch("https://login.yandex.ru/info?format=json", {
|
|
3506
3687
|
headers: { Authorization: `OAuth ${token}` },
|
|
@@ -11254,12 +11435,12 @@ function parseOptions(args) {
|
|
|
11254
11435
|
|
|
11255
11436
|
for (let index = 0; index < args.length; index += 1) {
|
|
11256
11437
|
const arg = args[index];
|
|
11257
|
-
if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--stream-json" || arg === "--stdio" || arg === "--system" || arg === "--headed" || arg === "--headless" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--full" || arg === "--unread" || arg === "--once" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--files" || arg === "--plan" || arg === "--trace" || arg === "--diff" || arg === "--stage" || arg === "--fts" || arg === "--bare" || arg === "--quiet" || arg === "--optional" || arg === "--project" || arg === "--dry-run" || arg === "--no-color" || arg === "--fail-on-empty" || arg === "--debug" || arg === "--fix" || arg === "--append" || arg === "--preserve-active" || arg === "--open") {
|
|
11438
|
+
if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--stream-json" || arg === "--stdio" || arg === "--system" || arg === "--headed" || arg === "--headless" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--full" || arg === "--unread" || arg === "--once" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--files" || arg === "--plan" || arg === "--trace" || arg === "--diff" || arg === "--stage" || arg === "--fts" || arg === "--bare" || arg === "--quiet" || arg === "--optional" || arg === "--project" || arg === "--dry-run" || arg === "--no-color" || arg === "--fail-on-empty" || arg === "--debug" || arg === "--fix" || arg === "--append" || arg === "--preserve-active" || arg === "--open" || arg === "--print-url") {
|
|
11258
11439
|
result[arg.slice(2)] = true;
|
|
11259
11440
|
} else if (arg === "--check" || arg === "--upgrade-node") {
|
|
11260
11441
|
result.check = true;
|
|
11261
11442
|
result[arg.slice(2)] = true;
|
|
11262
|
-
} else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--replace" || arg === "--text" || arg === "--path" || arg === "--depth" || arg === "--max-bytes" || arg === "--query" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--source" || arg === "--command" || arg === "--prompt" || arg === "--description" || arg === "--base-url" || arg === "--repo" || arg === "--model-dir" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format" || arg === "--output" || arg === "--schema" || arg === "--session" || arg === "--temperature" || arg === "--config" || arg === "--dataset" || arg === "--save" || arg === "--reasoning" || arg === "--agent" || arg === "--scope" || arg === "--selector" || arg === "--url" || arg === "--timeout" || arg === "--wait" || arg === "--viewport" || arg === "--press" || arg === "--script" || arg === "--auth-url" || arg === "--token-url" || arg === "--userinfo-url" || arg === "--client-id" || arg === "--client-secret" || arg === "--redirect-host" || arg === "--redirect-port" || arg === "--redirect-path" || arg === "--debug-file" || arg === "--from" || arg === "--to" || arg === "--radius" || arg === "--address" || arg === "--token") {
|
|
11443
|
+
} else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--replace" || arg === "--text" || arg === "--path" || arg === "--depth" || arg === "--max-bytes" || arg === "--query" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--source" || arg === "--command" || arg === "--prompt" || arg === "--description" || arg === "--base-url" || arg === "--repo" || arg === "--model-dir" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format" || arg === "--output" || arg === "--schema" || arg === "--session" || arg === "--temperature" || arg === "--config" || arg === "--dataset" || arg === "--save" || arg === "--reasoning" || arg === "--agent" || arg === "--scope" || arg === "--selector" || arg === "--url" || arg === "--timeout" || arg === "--wait" || arg === "--viewport" || arg === "--press" || arg === "--script" || arg === "--auth-url" || arg === "--token-url" || arg === "--userinfo-url" || arg === "--client-id" || arg === "--client-secret" || arg === "--redirect-url" || arg === "--redirect-host" || arg === "--redirect-port" || arg === "--redirect-path" || arg === "--debug-file" || arg === "--from" || arg === "--to" || arg === "--radius" || arg === "--address" || arg === "--token" || arg === "--app") {
|
|
11263
11444
|
result[arg.slice(2)] = args[index + 1];
|
|
11264
11445
|
index += 1;
|
|
11265
11446
|
} else {
|
package/test/smoke-test.js
CHANGED
|
@@ -58,9 +58,18 @@ assertIncludes(cliSource, "dedupeDatedOpenAiModels", "OpenAI model selection sho
|
|
|
58
58
|
assertIncludes(cliSource, "chooseLocalModel", "Local model selection should support IOLA and Ollama models");
|
|
59
59
|
assertIncludes(cliSource, "Другая Ollama-модель", "Local model selection should allow manual Ollama model names");
|
|
60
60
|
assertIncludes(cliSource, "chooseYandexServicesMenu", "Yandex Connector should have a service selection menu");
|
|
61
|
-
assertIncludes(cliSource, "
|
|
61
|
+
assertIncludes(cliSource, "Функции Яндекса.", "Yandex service selection should use a numbered menu");
|
|
62
|
+
assertIncludes(cliSource, "Выберите номера функций через запятую", "Yandex service selection should ask for numbers");
|
|
63
|
+
assertIncludes(cliSource, "getYandexServiceAuthState", "Yandex status should derive permissions from configured OAuth apps");
|
|
64
|
+
assertIncludes(cliSource, "OAuth-права встроенного приложения", "Yandex setup should report packaged OAuth app permissions");
|
|
62
65
|
assertIncludes(cliSource, "Выбрать активные функции можно командой /yandex", "Yandex setup should direct service selection to /yandex");
|
|
66
|
+
assertIncludes(cliSource, "runYandexBrowserOAuth", "Yandex setup should support browser OAuth flow");
|
|
67
|
+
assertIncludes(cliSource, "IOLA_YANDEX_OAUTH_CLIENT_ID", "Yandex setup should use a packaged/env OAuth client id");
|
|
68
|
+
assertIncludes(cliSource, "--app", "Yandex token command should persist tokens by OAuth app group");
|
|
63
69
|
assertNotIncludes(cliSource, "Сервисы через запятую [identity,disk]", "Yandex setup should not ask for services during connector setup");
|
|
70
|
+
if (!packageJson.files.includes("docs/assets/iola-oauth-icon.png")) {
|
|
71
|
+
throw new Error("package files should include the Yandex OAuth icon");
|
|
72
|
+
}
|
|
64
73
|
|
|
65
74
|
const commands = await runCli(["commands"]);
|
|
66
75
|
assertIncludes(commands, "iola browser status|install|open|text|html|screenshot|pdf|click|type|eval", "commands");
|
package/wiki/Yandex-Connector.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`Yandex Connector` - единая точка подключения пользовательских сервисов Яндекса в `iola-cli`.
|
|
4
4
|
|
|
5
|
-
Цель: пользователь
|
|
5
|
+
Цель: пользователь подключает сервисы Яндекса через обычный вход в браузере, а CLI хранит OAuth-токены локально. Какие функции CLI реально использует, пользователь выбирает отдельно через `/yandex`.
|
|
6
6
|
|
|
7
7
|
Секреты сохраняются только на компьютере пользователя в `~/.iola/secrets.json`. Они не отправляются на сервер IOLA и не попадают в `iola cloud backup`.
|
|
8
8
|
|
|
@@ -51,28 +51,29 @@ Backlog после первого контура:
|
|
|
51
51
|
|
|
52
52
|
## Как подключить
|
|
53
53
|
|
|
54
|
-
1.
|
|
55
|
-
2. Включите нужные scope. Для первого контура нужны:
|
|
56
|
-
- `login:info`;
|
|
57
|
-
- `login:email`;
|
|
58
|
-
- `cloud_api:disk.read`;
|
|
59
|
-
- `cloud_api:disk.write`;
|
|
60
|
-
- `cloud_api:disk.info`.
|
|
61
|
-
3. Запустите подключение. Оно не спрашивает список сервисов, а готовит OAuth-ссылку с максимальным набором прав коннектора:
|
|
54
|
+
1. Запустите подключение. Оно не спрашивает список сервисов, а открывает браузер для входа в Яндекс:
|
|
62
55
|
|
|
63
56
|
```bash
|
|
64
|
-
iola yandex setup
|
|
57
|
+
iola yandex setup
|
|
65
58
|
```
|
|
66
59
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
60
|
+
2. Авторизуйтесь в Яндексе и разрешите доступ. В текущем встроенном приложении `IOLA Yandex Core` запрашиваются права:
|
|
61
|
+
- `login:info`;
|
|
62
|
+
- `login:email`;
|
|
63
|
+
- `cloud_api:disk.read`;
|
|
64
|
+
- `cloud_api:disk.write`;
|
|
65
|
+
- `cloud_api:disk.info`;
|
|
66
|
+
- `mail:imap_full`;
|
|
67
|
+
- `mail:smtp`.
|
|
68
|
+
3. После успешной авторизации браузер вернется на локальную страницу `iola-cli`, а CLI сам сохранит OAuth-токен.
|
|
69
|
+
4. Если автоматический браузерный flow недоступен, используйте fallback для разработки:
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
|
+
iola yandex setup --client-id CLIENT_ID --print-url
|
|
72
73
|
iola yandex token set
|
|
73
74
|
```
|
|
74
75
|
|
|
75
|
-
|
|
76
|
+
5. Выберите, какие функции CLI реально использует:
|
|
76
77
|
|
|
77
78
|
```bash
|
|
78
79
|
iola yandex menu
|
|
@@ -84,7 +85,9 @@ iola yandex menu
|
|
|
84
85
|
/yandex
|
|
85
86
|
```
|
|
86
87
|
|
|
87
|
-
|
|
88
|
+
Меню `/yandex` работает как мастер настройки: сервисы выбираются номерами через запятую, а не вводом технических названий.
|
|
89
|
+
|
|
90
|
+
6. Проверьте:
|
|
88
91
|
|
|
89
92
|
```bash
|
|
90
93
|
iola yandex doctor
|
|
@@ -93,10 +96,42 @@ iola cloud doctor
|
|
|
93
96
|
|
|
94
97
|
Если включен `disk`, токен автоматически подключается и к старому облачному провайдеру `yandex-disk`, поэтому команды `iola cloud ...` продолжают работать.
|
|
95
98
|
|
|
99
|
+
## Что значит включить сервис
|
|
100
|
+
|
|
101
|
+
OAuth-права дают CLI разрешение обращаться к сервису Яндекса. Но для практической работы под каждый сервис нужны отдельные команды и тулы:
|
|
102
|
+
|
|
103
|
+
- `identity` - проверить пользователя и email;
|
|
104
|
+
- `disk` - папки, загрузка, скачивание, поиск файлов, публичные ссылки;
|
|
105
|
+
- `mail` - список писем, поиск, чтение письма, отправка письма после явного подтверждения;
|
|
106
|
+
- `calendar` - список событий, создание события, напоминания; требует отдельного OAuth-приложения/проверки прав;
|
|
107
|
+
- `contacts` - поиск контактов и карточки контактов; требует отдельного OAuth-приложения/проверки прав.
|
|
108
|
+
|
|
109
|
+
Включение сервиса в `/yandex` только разрешает CLI использовать соответствующую категорию. Если прав или тула еще нет, CLI должен честно показать это, а не имитировать работу.
|
|
110
|
+
|
|
111
|
+
## Иконка приложения
|
|
112
|
+
|
|
113
|
+
CLI поставляется с готовой иконкой OAuth-приложения. При установке она копируется в:
|
|
114
|
+
|
|
115
|
+
```text
|
|
116
|
+
~/.iola/assets/iola-oauth-icon.png
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
В репозитории файл лежит здесь:
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
docs/assets/iola-oauth-icon.png
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Если создается новое OAuth-приложение Яндекса вручную, эту иконку можно указать в поле "Иконка сервиса".
|
|
126
|
+
|
|
96
127
|
## Важно
|
|
97
128
|
|
|
98
129
|
Yandex Connector не является универсальным ключом ко всему Яндексу.
|
|
99
130
|
|
|
100
131
|
Обычные пользовательские сервисы работают через OAuth и scope. Yandex Cloud, YandexGPT и Geocoder требуют отдельные ключи, folder ID или настройки в Yandex Cloud.
|
|
101
132
|
|
|
133
|
+
Для браузерного подключения в сборке CLI уже указан public OAuth `client_id` приложения IOLA. Это не секретный ключ. Пользователь не должен создавать OAuth-приложение вручную.
|
|
134
|
+
|
|
135
|
+
Яндекс ограничивает количество разных групп сервисов в одном OAuth-приложении. Поэтому один токен не всегда может покрыть Диск, Почту, Календарь, Контакты, Wiki, Tracker и Forms сразу. CLI поддерживает группировку по нескольким OAuth-приложениям; текущий первый контур - `identity`, `disk`, `mail`.
|
|
136
|
+
|
|
102
137
|
CLI не должен автоматически оформлять покупки, вызывать такси, подтверждать доставку или выполнять платежи. Для таких сценариев допустима только подготовка ссылки, маршрута или списка, а финальное действие делает пользователь в приложении Яндекса.
|