@iola_adm/iola-cli 0.1.11 → 0.1.13

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
@@ -18,7 +18,11 @@ node --version
18
18
  npm --version
19
19
  ```
20
20
 
21
- Нужен Node.js `18` или новее. Если Node.js не установлен:
21
+ Нужен Node.js `22.5.0` или новее. Это нужно для встроенной SQLite-БД
22
+ `node:sqlite`, которую CLI будет использовать для локальной истории, кеша и
23
+ сессий без дополнительных нативных зависимостей.
24
+
25
+ Если Node.js не установлен или версия ниже `22.5.0`:
22
26
 
23
27
  ```bash
24
28
  # Windows
@@ -54,6 +58,8 @@ curl -fsSL https://ollama.com/install.sh | sh
54
58
  Диагностика ПК и подбор локальной модели:
55
59
 
56
60
  ```bash
61
+ npx -y @iola_adm/iola-cli init
62
+ npx -y @iola_adm/iola-cli init --upgrade-node
57
63
  npx -y @iola_adm/iola-cli ai doctor
58
64
  npx -y @iola_adm/iola-cli ai setup ollama
59
65
  ```
@@ -81,6 +87,10 @@ iola agent
81
87
  iola chat
82
88
  iola init
83
89
  iola doctor
90
+ iola db status
91
+ iola db init
92
+ iola history --limit 20
93
+ iola history clear
84
94
  iola config get
85
95
  iola config set api.baseUrl https://apiiola.yasg.ru/api/v1
86
96
  iola config reset
@@ -133,6 +143,7 @@ iola agent
133
143
  /help
134
144
  /health
135
145
  /doctor
146
+ /db status
136
147
  /config get
137
148
  /layers
138
149
  /data schools --limit 10
@@ -154,6 +165,7 @@ iola agent
154
165
  /provider
155
166
  /config
156
167
  /history
168
+ /history --limit 20
157
169
  /clear
158
170
  /update
159
171
  /init
@@ -265,6 +277,34 @@ CLI дает прямой терминальный доступ к открыт
265
277
  командам подключения MCP/skill, AI-запросам через Ollama/OpenAI/OpenRouter,
266
278
  интерактивному агентному режиму, экспорту данных и проверке обновлений.
267
279
 
280
+ ## Локальная SQLite-БД
281
+
282
+ CLI использует встроенный `node:sqlite` и хранит локальную БД в профиле
283
+ пользователя:
284
+
285
+ ```text
286
+ %USERPROFILE%\.iola\iola.db
287
+ ```
288
+
289
+ БД создается автоматически при установке npm-пакета и при `iola init`.
290
+ В ней хранятся история AI-запросов, контекст ответа, ошибки выполнения,
291
+ служебная таблица версии схемы, а также подготовлены таблицы для кеша API и
292
+ сохраненных выборок.
293
+
294
+ Команды:
295
+
296
+ ```bash
297
+ iola db status
298
+ iola db init
299
+ iola db reset
300
+ iola history --limit 20
301
+ iola history --json
302
+ iola history clear
303
+ ```
304
+
305
+ Ключи OpenAI/OpenRouter в SQLite не сохраняются. Они остаются в локальном
306
+ `secrets.json` или в переменных окружения.
307
+
268
308
  ## Переменные окружения
269
309
 
270
310
  ```bash
package/bin/iola.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env -S node --no-warnings
2
2
 
3
3
  import { main } from "../src/cli.js";
4
4
 
@@ -6,4 +6,3 @@ main(process.argv.slice(2)).catch((error) => {
6
6
  console.error(error instanceof Error ? error.message : String(error));
7
7
  process.exitCode = 1;
8
8
  });
9
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "CLI и AI-агент для работы с открытыми данными городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
@@ -15,6 +15,11 @@
15
15
  "bin": {
16
16
  "iola": "bin/iola.js"
17
17
  },
18
+ "scripts": {
19
+ "postinstall": "node --no-warnings bin/iola.js db init --silent || true",
20
+ "start": "node --no-warnings bin/iola.js",
21
+ "test": "node --no-warnings --check bin/iola.js && node --no-warnings --check src/cli.js"
22
+ },
18
23
  "files": [
19
24
  "bin",
20
25
  "src",
@@ -25,10 +30,6 @@
25
30
  "publishConfig": {
26
31
  "access": "public"
27
32
  },
28
- "scripts": {
29
- "start": "node bin/iola.js",
30
- "test": "node --check bin/iola.js && node --check src/cli.js"
31
- },
32
33
  "keywords": [
33
34
  "yoshkar-ola",
34
35
  "open-data",
@@ -36,6 +37,6 @@
36
37
  "cli"
37
38
  ],
38
39
  "engines": {
39
- "node": ">=18"
40
+ "node": ">=22.5.0"
40
41
  }
41
42
  }
package/src/cli.js CHANGED
@@ -1,15 +1,20 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { mkdirSync } from "node:fs";
2
3
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
5
6
  import readline from "node:readline/promises";
6
7
  import { stdin as input, stdout as output } from "node:process";
8
+ import { DatabaseSync } from "node:sqlite";
7
9
 
8
10
  const API_BASE_URL = process.env.IOLA_API_BASE_URL || "https://apiiola.yasg.ru/api/v1";
9
11
  const MCP_BASE_URL = process.env.IOLA_MCP_BASE_URL || "https://apiiola.yasg.ru";
12
+ const MIN_NODE_VERSION = "22.5.0";
10
13
  const CONFIG_DIR = path.join(os.homedir(), ".iola");
11
14
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
12
15
  const SECRETS_FILE = path.join(CONFIG_DIR, "secrets.json");
16
+ const DB_FILE = path.join(CONFIG_DIR, "iola.db");
17
+ const DB_SCHEMA_VERSION = 1;
13
18
  const DEFAULT_AI_CONFIG = {
14
19
  api: {
15
20
  baseUrl: "https://apiiola.yasg.ru/api/v1",
@@ -75,6 +80,8 @@ const COMMANDS = new Map([
75
80
  ["version", showVersion],
76
81
  ["update", checkUpdate],
77
82
  ["doctor", doctor],
83
+ ["db", handleDb],
84
+ ["history", handleHistory],
78
85
  ["config", handleConfig],
79
86
  ["banner", showBanner],
80
87
  ["agent", startAgent],
@@ -94,6 +101,11 @@ const COMMANDS = new Map([
94
101
 
95
102
  export async function main(argv) {
96
103
  const [command = "help", ...args] = argv;
104
+ const nodeStatus = getNodeRequirementStatus();
105
+ if (!nodeStatus.ok && !["help", "version", "doctor", "init"].includes(command)) {
106
+ throw new Error(`Нужен Node.js ${MIN_NODE_VERSION} или новее. Сейчас: ${nodeStatus.current}. Запустите: iola init --upgrade-node`);
107
+ }
108
+
97
109
  const handler = COMMANDS.get(command);
98
110
 
99
111
  if (!handler) {
@@ -113,6 +125,10 @@ Usage:
113
125
  iola chat
114
126
  iola init
115
127
  iola doctor
128
+ iola db status
129
+ iola db init
130
+ iola history [--limit 20]
131
+ iola history clear
116
132
  iola config get
117
133
  iola config set api.baseUrl URL
118
134
  iola config set api.mcpBaseUrl URL
@@ -148,6 +164,9 @@ Usage:
148
164
  Environment:
149
165
  IOLA_API_BASE_URL default: ${API_BASE_URL}
150
166
  IOLA_MCP_BASE_URL default: ${MCP_BASE_URL}
167
+
168
+ Requirements:
169
+ Node.js >= ${MIN_NODE_VERSION}
151
170
  `);
152
171
  }
153
172
 
@@ -216,7 +235,16 @@ async function handleAgentLine(line, state) {
216
235
  }
217
236
 
218
237
  if (command === "history") {
219
- printAgentHistory(state.history);
238
+ if (args.length > 0) {
239
+ await handleHistory(args);
240
+ } else {
241
+ printAgentHistory(state.history);
242
+ }
243
+ return false;
244
+ }
245
+
246
+ if (command === "db") {
247
+ await handleDb(args);
220
248
  return false;
221
249
  }
222
250
 
@@ -298,6 +326,8 @@ async function handleAgentLine(line, state) {
298
326
  const mapped = {
299
327
  health: ["health", args],
300
328
  doctor: ["doctor", args],
329
+ db: ["db", args],
330
+ history: ["history", args],
301
331
  config: ["config", args],
302
332
  layers: ["layers", args],
303
333
  data: ["data", args],
@@ -324,6 +354,7 @@ function printAgentHelp() {
324
354
  /help
325
355
  /health
326
356
  /doctor
357
+ /db status
327
358
  /config get
328
359
  /config set api.baseUrl URL
329
360
  /layers
@@ -348,6 +379,7 @@ function printAgentHelp() {
348
379
  /provider
349
380
  /model
350
381
  /history
382
+ /history --limit 20
351
383
  /clear
352
384
  /banner
353
385
  /update
@@ -461,7 +493,11 @@ async function doctor(args = []) {
461
493
  version: packageJson.default.version,
462
494
  npmLatest: latest || "-",
463
495
  update: getUpdateStatus(packageJson.default.version, latest),
496
+ node: process.version,
497
+ nodeRequired: `>=${MIN_NODE_VERSION}`,
498
+ nodeStatus: getNodeRequirementStatus().ok ? "ok" : "upgrade-required",
464
499
  },
500
+ db: getDbStatus(),
465
501
  api: {
466
502
  baseUrl: apiBaseUrl,
467
503
  mcpBaseUrl,
@@ -487,6 +523,9 @@ async function doctor(args = []) {
487
523
  console.log("CLI");
488
524
  printKeyValue(report.cli);
489
525
  console.log("");
526
+ console.log("SQLite");
527
+ printKeyValue(report.db);
528
+ console.log("");
490
529
  console.log("API/MCP");
491
530
  printKeyValue(report.api);
492
531
  console.log("");
@@ -514,6 +553,69 @@ function getUpdateStatus(current, latest) {
514
553
  return "ok";
515
554
  }
516
555
 
556
+ function getNodeRequirementStatus() {
557
+ const current = process.versions.node;
558
+ return {
559
+ current,
560
+ required: MIN_NODE_VERSION,
561
+ ok: compareVersions(current, MIN_NODE_VERSION) >= 0,
562
+ };
563
+ }
564
+
565
+ async function offerNodeUpgrade(options, status) {
566
+ console.log(`Текущая версия Node.js: ${status.current}. Нужна ${MIN_NODE_VERSION} или новее.`);
567
+
568
+ if (!process.stdin.isTTY && !options["upgrade-node"]) {
569
+ printNodeUpgradeInstructions();
570
+ return;
571
+ }
572
+
573
+ const shouldUpgrade = options["upgrade-node"] || (await confirm("Обновить Node.js установщиком сейчас? [y/N] "));
574
+
575
+ if (!shouldUpgrade) {
576
+ printNodeUpgradeInstructions();
577
+ return;
578
+ }
579
+
580
+ await upgradeNodeWithInstaller();
581
+ console.log("");
582
+ console.log("После обновления перезапустите терминал и проверьте:");
583
+ console.log(" node --version");
584
+ console.log(" iola init");
585
+ }
586
+
587
+ function printNodeUpgradeInstructions() {
588
+ console.log("Обновите Node.js:");
589
+ console.log(" Windows: winget install OpenJS.NodeJS.LTS");
590
+ console.log(" macOS: brew install node");
591
+ console.log(" Linux: curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - && sudo apt-get install -y nodejs");
592
+ }
593
+
594
+ async function upgradeNodeWithInstaller() {
595
+ if (process.platform === "win32") {
596
+ try {
597
+ await runCommand("winget", ["upgrade", "OpenJS.NodeJS.LTS", "--accept-package-agreements", "--accept-source-agreements"], { inherit: true });
598
+ } catch {
599
+ await runCommand("winget", ["install", "OpenJS.NodeJS.LTS", "--accept-package-agreements", "--accept-source-agreements"], { inherit: true });
600
+ }
601
+ return;
602
+ }
603
+
604
+ if (process.platform === "darwin") {
605
+ try {
606
+ await runCommand("brew", ["upgrade", "node"], { inherit: true });
607
+ } catch {
608
+ await runCommand("brew", ["install", "node"], { inherit: true });
609
+ }
610
+ return;
611
+ }
612
+
613
+ await runCommand("sh", [
614
+ "-c",
615
+ "curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - && sudo apt-get install -y nodejs",
616
+ ], { inherit: true });
617
+ }
618
+
517
619
  async function checkConfiguredModel(config) {
518
620
  if (config.ai.provider !== "ollama") {
519
621
  return "external-api";
@@ -536,17 +638,29 @@ async function checkConfiguredModel(config) {
536
638
 
537
639
  async function initCli(args = []) {
538
640
  const options = parseOptions(args);
641
+ const nodeStatus = getNodeRequirementStatus();
539
642
 
540
643
  showBanner();
541
644
  console.log("Проверка окружения");
645
+ initDatabase();
646
+ const dbStatus = getDbStatus();
542
647
  printKeyValue({
543
648
  node: process.version,
649
+ node_required: `>=${MIN_NODE_VERSION}`,
650
+ node_status: nodeStatus.ok ? "ok" : "нужно обновить",
544
651
  npm: await getCommandVersion("npm", ["--version"]),
545
652
  api: await probeEndpoint(`${await getMcpBaseUrl()}/mcp-health`),
546
653
  mcp: await getMcpBaseUrl(),
654
+ sqlite: dbStatus.status,
655
+ sqlite_file: dbStatus.file,
547
656
  });
548
657
  console.log("");
549
658
 
659
+ if (!nodeStatus.ok) {
660
+ await offerNodeUpgrade(options, nodeStatus);
661
+ console.log("");
662
+ }
663
+
550
664
  await aiDoctor(options.json ? ["--json"] : []);
551
665
 
552
666
  if (!process.stdin.isTTY || options.yes) {
@@ -680,6 +794,65 @@ async function handleConfig(args) {
680
794
  throw new Error("Команды config: get, set, reset.");
681
795
  }
682
796
 
797
+ async function handleDb(args) {
798
+ const [action = "status"] = args;
799
+ const options = parseOptions(args);
800
+
801
+ if (action === "init") {
802
+ initDatabase();
803
+ if (!options.silent) {
804
+ console.log(`SQLite-БД готова: ${DB_FILE}`);
805
+ }
806
+ return;
807
+ }
808
+
809
+ if (action === "status") {
810
+ printKeyValue(getDbStatus());
811
+ return;
812
+ }
813
+
814
+ if (action === "reset") {
815
+ const shouldReset = await confirm("Удалить локальную SQLite-БД iola.db? [y/N] ");
816
+ if (!shouldReset) {
817
+ console.log("Сброс отменен.");
818
+ return;
819
+ }
820
+ await rm(DB_FILE, { force: true });
821
+ initDatabase();
822
+ console.log(`SQLite-БД пересоздана: ${DB_FILE}`);
823
+ return;
824
+ }
825
+
826
+ throw new Error("Команды db: status, init, reset.");
827
+ }
828
+
829
+ async function handleHistory(args) {
830
+ const [action] = args;
831
+ const options = parseOptions(args);
832
+
833
+ if (action === "clear") {
834
+ clearHistory();
835
+ console.log("История очищена.");
836
+ return;
837
+ }
838
+
839
+ const rows = listHistory(Number(options.limit || 20));
840
+
841
+ if (options.json) {
842
+ printJson(rows);
843
+ return;
844
+ }
845
+
846
+ printTable(rows, [
847
+ ["id", "ID"],
848
+ ["created_at", "Дата"],
849
+ ["profile", "Профиль"],
850
+ ["provider", "Провайдер"],
851
+ ["question", "Вопрос"],
852
+ ["answer", "Ответ"],
853
+ ]);
854
+ }
855
+
683
856
  async function aiDoctor(args) {
684
857
  const options = parseOptions(args);
685
858
  const diagnostics = await getLocalDiagnostics();
@@ -1167,6 +1340,142 @@ async function deleteAiKey(provider) {
1167
1340
  console.log(`Локальный ключ ${provider} удален.`);
1168
1341
  }
1169
1342
 
1343
+ function openDatabase() {
1344
+ const db = new DatabaseSync(DB_FILE);
1345
+ db.exec("PRAGMA busy_timeout = 5000;");
1346
+ return db;
1347
+ }
1348
+
1349
+ function initDatabase() {
1350
+ mkdirSyncSafe(CONFIG_DIR);
1351
+ const db = openDatabase();
1352
+ try {
1353
+ db.exec(`
1354
+ CREATE TABLE IF NOT EXISTS meta (
1355
+ key TEXT PRIMARY KEY,
1356
+ value TEXT NOT NULL
1357
+ );
1358
+ CREATE TABLE IF NOT EXISTS ask_history (
1359
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1360
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1361
+ profile TEXT,
1362
+ provider TEXT,
1363
+ model TEXT,
1364
+ question TEXT NOT NULL,
1365
+ answer TEXT NOT NULL,
1366
+ context_json TEXT NOT NULL,
1367
+ error TEXT
1368
+ );
1369
+ CREATE INDEX IF NOT EXISTS idx_ask_history_created_at ON ask_history(created_at DESC);
1370
+ CREATE TABLE IF NOT EXISTS api_cache (
1371
+ key TEXT PRIMARY KEY,
1372
+ url TEXT NOT NULL,
1373
+ response_json TEXT NOT NULL,
1374
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1375
+ expires_at TEXT
1376
+ );
1377
+ CREATE TABLE IF NOT EXISTS saved_views (
1378
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1379
+ name TEXT NOT NULL UNIQUE,
1380
+ dataset TEXT NOT NULL,
1381
+ query_json TEXT NOT NULL,
1382
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
1383
+ );
1384
+ `);
1385
+ db.prepare(`
1386
+ INSERT INTO meta(key, value) VALUES ('schema_version', ?)
1387
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
1388
+ `).run(String(DB_SCHEMA_VERSION));
1389
+ } finally {
1390
+ db.close();
1391
+ }
1392
+ }
1393
+
1394
+ function mkdirSyncSafe(directory) {
1395
+ try {
1396
+ mkdirSync(directory, { recursive: true });
1397
+ } catch {
1398
+ // Directory creation is retried by write operations where needed.
1399
+ }
1400
+ }
1401
+
1402
+ function getDbStatus() {
1403
+ try {
1404
+ initDatabase();
1405
+ const db = openDatabase();
1406
+ try {
1407
+ const schema = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
1408
+ const history = db.prepare("SELECT COUNT(*) AS count FROM ask_history").get();
1409
+ return {
1410
+ status: "ok",
1411
+ file: DB_FILE,
1412
+ schema: schema?.value || "-",
1413
+ history: history?.count ?? 0,
1414
+ };
1415
+ } finally {
1416
+ db.close();
1417
+ }
1418
+ } catch (error) {
1419
+ return {
1420
+ status: "error",
1421
+ file: DB_FILE,
1422
+ schema: "-",
1423
+ history: "-",
1424
+ error: error instanceof Error ? error.message : String(error),
1425
+ };
1426
+ }
1427
+ }
1428
+
1429
+ function recordAskHistory({ question, answer, providerConfig, dataContext, error }) {
1430
+ try {
1431
+ initDatabase();
1432
+ const db = openDatabase();
1433
+ try {
1434
+ db.prepare(`
1435
+ INSERT INTO ask_history(profile, provider, model, question, answer, context_json, error)
1436
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1437
+ `).run(
1438
+ providerConfig.name || "",
1439
+ providerConfig.provider || "",
1440
+ providerConfig.model || "",
1441
+ question,
1442
+ answer,
1443
+ JSON.stringify(dataContext),
1444
+ error || "",
1445
+ );
1446
+ } finally {
1447
+ db.close();
1448
+ }
1449
+ } catch {
1450
+ // History must never break the main answer path.
1451
+ }
1452
+ }
1453
+
1454
+ function listHistory(limit) {
1455
+ initDatabase();
1456
+ const db = openDatabase();
1457
+ try {
1458
+ return db.prepare(`
1459
+ SELECT id, created_at, profile, provider, model, question, answer, error
1460
+ FROM ask_history
1461
+ ORDER BY id DESC
1462
+ LIMIT ?
1463
+ `).all(limit);
1464
+ } finally {
1465
+ db.close();
1466
+ }
1467
+ }
1468
+
1469
+ function clearHistory() {
1470
+ initDatabase();
1471
+ const db = openDatabase();
1472
+ try {
1473
+ db.exec("DELETE FROM ask_history");
1474
+ } finally {
1475
+ db.close();
1476
+ }
1477
+ }
1478
+
1170
1479
  function assertKeyProvider(provider) {
1171
1480
  if (provider !== "openai" && provider !== "openrouter") {
1172
1481
  throw new Error("Провайдер должен быть openai или openrouter.");
@@ -1258,7 +1567,18 @@ async function aiAsk(args, context = {}) {
1258
1567
  const providerConfig = resolveAiProfile(config, options);
1259
1568
  const dataContext = await buildDataContext(question);
1260
1569
  const messages = buildAiMessages(question, dataContext, context.history || []);
1261
- const answer = await callAiProvider(providerConfig, messages);
1570
+ let answer = "";
1571
+ let errorMessage = "";
1572
+
1573
+ try {
1574
+ answer = await callAiProvider(providerConfig, messages);
1575
+ } catch (error) {
1576
+ errorMessage = error instanceof Error ? error.message : String(error);
1577
+ recordAskHistory({ question, answer: "", providerConfig, dataContext, error: errorMessage });
1578
+ throw error;
1579
+ }
1580
+
1581
+ recordAskHistory({ question, answer, providerConfig, dataContext, error: "" });
1262
1582
 
1263
1583
  console.log(answer);
1264
1584
  return answer;
@@ -1742,10 +2062,11 @@ function parseOptions(args) {
1742
2062
 
1743
2063
  for (let index = 0; index < args.length; index += 1) {
1744
2064
  const arg = args[index];
1745
- if (arg === "--json" || arg === "--yes") {
2065
+ if (arg === "--json" || arg === "--yes" || arg === "--silent") {
1746
2066
  result[arg.slice(2)] = true;
1747
- } else if (arg === "--check") {
2067
+ } else if (arg === "--check" || arg === "--upgrade-node") {
1748
2068
  result.check = true;
2069
+ result[arg.slice(2)] = true;
1749
2070
  } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--base-url" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format") {
1750
2071
  result[arg.slice(2)] = args[index + 1];
1751
2072
  index += 1;