dsh-edt-projects 0.0.0-stage → 0.9.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Iosif Pravets
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,29 @@
1
- # Temporary Holding Version
1
+ # dsh-edt-projects
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Плагин [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) для
4
+ разработки на 1С: держит **1C:EDT** подключённым через
5
+ [EDT-MCP](https://github.com/DitriXNew/EDT-MCP), поднимает **BSL Language Server**
6
+ по требованию, добавляет настройки на проект, правила для модели и навыки
7
+ SonarQube/GitLab.
8
+
9
+ ## Установка
10
+
11
+ ```text
12
+ plugin_manager { action: "install_bundle", target: "dsh-edt-projects" }
13
+ ```
14
+
15
+ Нужны EDT 2026.1/2026.2 с установленным EDT-MCP и запущенным MCP-сервером
16
+ (по умолчанию `127.0.0.1:8765`) — подробности и настройка в
17
+ [README репозитория](https://github.com/pravets/dsh-edt-projects#readme).
18
+
19
+ ## Что внутри
20
+
21
+ - `lib/` — хаб MCP-серверов: монтаж по требованию (`mcp_pin`), настройки на
22
+ проект, правила для системного промпта, провайдер навыков.
23
+ - `skills/` — навыки `edt-mcp-project-sonar` и `edt-mcp-project-gitlab`,
24
+ включаются флагами `sonar.enabled` и `gitlab.enabled`.
25
+ - `cordis.patch.yml` — состав серверов, который применяет менеджер плагинов.
26
+
27
+ ## Лицензия
28
+
29
+ MIT, см. [LICENSE](LICENSE).
@@ -0,0 +1,83 @@
1
+ # Хаб MCP-серверов для разработки на 1С.
2
+ #
3
+ # Серверы описываются здесь и подключаются плагином mcp-hub (lib/index.js):
4
+ # - `lazy: true` — не занимать контекст: сервер монтируется по вызову mcp_pin
5
+ # - `provision: true` — параметры запуска добираются на mcp_pin (BSL LS скачивается)
6
+ # - `shared: true` — одна копия на процесс; остальные серверы монтируются
7
+ # в скоуп агента (параллельные worktree не мешают друг другу)
8
+ # - `unavailable` — сервер описан, но настроить его нечем (mcp_pin объяснит, чем)
9
+ #
10
+ # Настройки можно переопределить без перезапуска DSH — файлами
11
+ # $DSH_HOME/dsh-1c.json (пользователь) и <cwd>/.dsh-1c.json (проект):
12
+ # { "wiki": { "paths": ["C:\\git\\dev-llm-wiki"], "entry": "wiki/index.md" },
13
+ # "servers": { "edt": { "url": "http://127.0.0.1:8765/mcp" },
14
+ # "bsl": { "root": "C:\\git\\trade\\src\\cf" } },
15
+ # "tests": { "yaxunit": { "enabled": true, "path": "tests/yaxunit", "project": "cfe_yaxunit" },
16
+ # "vanessa": { "enabled": true, "path": "features" } } }
17
+ # После правки файла: mcp_reload (или mcp_pin заново).
18
+ # Флаги tests включают правила тестируемости: YAxUnit — закладывать тестируемость
19
+ # и писать тесты в указанный проект, Vanessa — предлагать сценарные тесты.
20
+ #
21
+ # Переменные окружения (префикс DSH_1C_ — чтобы ни с чем не пересекаться):
22
+ # EDT_MCP_URL адрес MCP-сервера EDT (по умолчанию 127.0.0.1:8765)
23
+ # EDT_MCP_TOKEN токен, если он включён в EDT
24
+ # DSH_1C_WIKI пути к вики через «;»
25
+ # DSH_1C_WIKI_ENTRY входная точка вики, например wiki/index.md
26
+ # DSH_1C_BSL_LS_JAR готовый bsl-language-server jar (главнее всего)
27
+ # DSH_1C_BSL_LS_URL прямой URL на jar вместо GitHub
28
+ # DSH_1C_BSL_LS_VERSION тег релиза, например v1.0.7 (по умолчанию последний)
29
+ # DSH_1C_BSL_LS_GITHUB_TOKEN токен GitHub для API (иначе GITHUB_TOKEN, GH_TOKEN):
30
+ # без него загрузка идёт со страниц релизов и не упирается
31
+ # в лимит API 60/ч на IP
32
+ # DSH_1C_BSL_LS_CACHE каталог кэша ($DSH_HOME/cache/bsl-language-server)
33
+ # DSH_1C_BSL_LS_DOWNLOAD «0» — не скачивать, использовать только готовый jar
34
+ # DSH_1C_BSL_LS_JAVA java-команда (по умолчанию java)
35
+ # DSH_1C_BSL_LS_ROOT корень исходников для BSL LS
36
+ # DSH_1C_BSL_MCP_URL адрес уже запущенного BSL LS по Streamable HTTP
37
+ # TASKS_MCP_URL адрес MCP-сервера системы задач
38
+ # TASKS_MCP_TOKEN токен системы задач
39
+ - insert:
40
+ - id: mcp-hub
41
+ name: 'dsh-edt-projects'
42
+ config:
43
+ wiki:
44
+ paths: !!js 'process.env.DSH_1C_WIKI ? process.env.DSH_1C_WIKI.split(";").filter(Boolean) : []'
45
+ entry: !!js 'process.env.DSH_1C_WIKI_ENTRY ?? ""'
46
+ servers:
47
+ # EDT один на процесс: одна копия, чтобы агенты не поднимали лишние сессии.
48
+ edt:
49
+ shared: true
50
+ lazy: false
51
+ transport: streamable-http
52
+ url: !!js 'process.env.EDT_MCP_URL ?? "http://127.0.0.1:8765/mcp"'
53
+ headers: !!js 'process.env.EDT_MCP_TOKEN ? { Authorization: `Bearer ${process.env.EDT_MCP_TOKEN}` } : {}'
54
+ # Операции EDT (update_database, run_yaxunit_tests, валидация) идут дольше 60 с.
55
+ toolCallTimeoutMs: 900000
56
+ # EDT часто закрыт: не валить активацию, ждать сервер.
57
+ failOnStartupError: false
58
+ reconnect:
59
+ enabled: true
60
+ initialDelayMs: 1000
61
+ maxDelayMs: 30000
62
+ maxAttempts: 1000
63
+
64
+ # BSL Language Server: 10 инструментов, нужны эпизодически.
65
+ # provision: на mcp_pin берётся готовый jar из DSH_1C_BSL_LS_JAR, иначе из
66
+ # кэша, иначе скачивается последний релиз с GitHub (sha256 сверяется).
67
+ # Корень по умолчанию — servers.bsl.root из файла настроек проекта.
68
+ bsl:
69
+ lazy: true
70
+ provision: true
71
+ toolCallTimeoutMs: 120000
72
+ failOnStartupError: false
73
+
74
+ # Система задач: место зарезервировано под любой MCP-сервер.
75
+ # Конкретный продукт (YouTrack, Jira, GitLab, GitHub) — это его адрес и токен.
76
+ tasks:
77
+ lazy: true
78
+ unavailable: !!js '!process.env.TASKS_MCP_URL'
79
+ transport: streamable-http
80
+ url: !!js 'process.env.TASKS_MCP_URL ?? "http://127.0.0.1:8090/mcp"'
81
+ headers: !!js 'process.env.TASKS_MCP_TOKEN ? { Authorization: `Bearer ${process.env.TASKS_MCP_TOKEN}` } : {}'
82
+ toolCallTimeoutMs: 120000
83
+ failOnStartupError: false
package/icon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="1C:EDT MCP">
2
+ <rect x="3" y="3" width="58" height="58" rx="14" fill="#132a4a"/>
3
+ <path d="M18 23h20M18 32h28M18 41h14" fill="none" stroke="#6fb3ff" stroke-width="4" stroke-linecap="round"/>
4
+ <circle cx="47" cy="42" r="7" fill="#38d17a"/>
5
+ <path d="M43.5 42.2l2.6 2.6 4.6-5" fill="none" stroke="#132a4a" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
6
+ </svg>
package/lib/bsl.mjs ADDED
@@ -0,0 +1,455 @@
1
+ // Загрузка BSL Language Server по требованию: явный путь, прямой URL, страницы
2
+ // релизов GitHub или API (с проверкой sha256).
3
+ //
4
+ // GitHub API без токена даёт 60 запросов в час на IP, поэтому источник выбирается
5
+ // по наличию токена: с токеном — API первым, без токена — сначала обычные
6
+ // страницы релизов (они не в лимите API), а API лишь как резерв.
7
+ //
8
+ // Все переменные окружения — с префиксом DSH_1C_, чтобы не пересекаться ни с
9
+ // чем на машине. Старые имена (BSL_LS_JAR, BSL_MCP_URL…) принимаются как fallback.
10
+ //
11
+ // DSH_1C_BSL_LS_JAR готовый jar (главнее всего остального)
12
+ // DSH_1C_BSL_LS_URL прямой URL на jar (зеркало, офлайн-раздача)
13
+ // DSH_1C_BSL_LS_VERSION тег релиза, например v1.0.7 (по умолчанию последний)
14
+ // DSH_1C_BSL_LS_GITHUB_TOKEN токен GitHub для API (иначе GITHUB_TOKEN, GH_TOKEN)
15
+ // DSH_1C_BSL_LS_CACHE каталог кэша (по умолчанию $DSH_HOME/cache/bsl-language-server)
16
+ // DSH_1C_BSL_LS_DOWNLOAD «0» — запретить скачивание
17
+ // DSH_1C_BSL_LS_JAVA java-команда (по умолчанию java)
18
+ // DSH_1C_BSL_LS_CWD рабочий каталог сервера (корень исходников)
19
+ // DSH_1C_BSL_LS_ROOT корень рабочего пространства (главнее CWD)
20
+ // DSH_1C_BSL_LS_PORT порт встроенного веб-сервера (по умолчанию свободный)
21
+ // DSH_1C_BSL_LS_MODE hybrid (по умолчанию) или stdio
22
+ // DSH_1C_BSL_MCP_URL адрес уже запущенного BSL LS по Streamable HTTP
23
+ import { createHash } from 'node:crypto';
24
+ import { spawn } from 'node:child_process';
25
+ import { createWriteStream, existsSync, mkdirSync, readdirSync, rmSync, renameSync, statSync } from 'node:fs';
26
+ import { connect, createServer } from 'node:net';
27
+ import { homedir } from 'node:os';
28
+ import { basename, join } from 'node:path';
29
+ import { Readable } from 'node:stream';
30
+ import { pathToFileURL } from 'node:url';
31
+
32
+ export const REPO = '1c-syntax/bsl-language-server';
33
+ /** 130 МБ по медленному каналу — запас большой. */
34
+ export const DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000;
35
+ const ASSET_PATTERN = /-exec\.jar$/;
36
+ const RELEASES_URL = `https://github.com/${REPO}/releases`;
37
+ const API_REPO_URL = `https://api.github.com/repos/${REPO}`;
38
+ const REQUEST_TIMEOUT_MS = 30_000;
39
+
40
+ /** GitHub API ответил лимитом запросов: сообщение должно подсказывать выход. */
41
+ export class RateLimitError extends Error {
42
+ constructor(message, { resetAt, status } = {}) {
43
+ super(message);
44
+ this.name = 'RateLimitError';
45
+ this.resetAt = resetAt;
46
+ this.status = status;
47
+ }
48
+ }
49
+
50
+ /** Скачанный файл не совпал по sha256: повтор без сверки недопустим. */
51
+ export class IntegrityError extends Error {
52
+ constructor(message) {
53
+ super(message);
54
+ this.name = 'IntegrityError';
55
+ }
56
+ }
57
+
58
+ const first = (env, ...names) => names.map((name) => env[name]).find((value) => value !== undefined && value !== '');
59
+ const isOff = (value) => ['0', 'false', 'no', 'off'].includes(String(value).toLowerCase());
60
+
61
+ /** Разбирает окружение в настройки загрузчика. */
62
+ export function bslSettings(env = process.env) {
63
+ const download = first(env, 'DSH_1C_BSL_LS_DOWNLOAD');
64
+ return {
65
+ jar: first(env, 'DSH_1C_BSL_LS_JAR', 'BSL_LS_JAR'),
66
+ url: first(env, 'DSH_1C_BSL_LS_URL'),
67
+ version: first(env, 'DSH_1C_BSL_LS_VERSION'),
68
+ githubToken: first(env, 'DSH_1C_BSL_LS_GITHUB_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN'),
69
+ mcpUrl: first(env, 'DSH_1C_BSL_MCP_URL', 'BSL_MCP_URL'),
70
+ cacheDir: first(env, 'DSH_1C_BSL_LS_CACHE') ?? join(env.DSH_HOME ?? join(homedir(), '.dsh'), 'cache', 'bsl-language-server'),
71
+ java: first(env, 'DSH_1C_BSL_LS_JAVA', 'BSL_LS_JAVA') ?? 'java',
72
+ cwd: first(env, 'DSH_1C_BSL_LS_CWD', 'BSL_LS_CWD') ?? '',
73
+ root: first(env, 'DSH_1C_BSL_LS_ROOT', 'DSH_1C_BSL_LS_CWD', 'BSL_LS_CWD') ?? process.cwd(),
74
+ port: first(env, 'DSH_1C_BSL_LS_PORT'),
75
+ mode: (first(env, 'DSH_1C_BSL_LS_MODE') ?? 'hybrid').toLowerCase(),
76
+ downloadAllowed: download === undefined ? true : !isOff(download),
77
+ };
78
+ }
79
+
80
+ /** Выбирает из релиза исполняемый jar и его sha256. */
81
+ export function selectExecAsset(release) {
82
+ const assets = Array.isArray(release?.assets) ? release.assets : [];
83
+ const asset = assets.find((item) => ASSET_PATTERN.test(String(item.name)));
84
+ if (!asset) throw new Error(`в релизе ${release?.tag_name ?? '?'} нет ассета *-exec.jar`);
85
+ const digest = typeof asset.digest === 'string' && asset.digest.startsWith('sha256:') ? asset.digest.slice('sha256:'.length) : undefined;
86
+ return {
87
+ tag: release.tag_name,
88
+ name: asset.name,
89
+ url: asset.browser_download_url,
90
+ size: asset.size,
91
+ sha256: digest,
92
+ };
93
+ }
94
+
95
+ function cachedJars(cacheDir) {
96
+ if (!existsSync(cacheDir)) return [];
97
+ return readdirSync(cacheDir)
98
+ .filter((name) => name.endsWith('-exec.jar'))
99
+ .map((name) => join(cacheDir, name))
100
+ .sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs);
101
+ }
102
+
103
+ async function downloadToFile(url, target, { fetchImpl, log, timeoutMs = DOWNLOAD_TIMEOUT_MS, expectSha256 }) {
104
+ mkdirSync(join(target, '..'), { recursive: true });
105
+ const partial = `${target}.part`;
106
+ log(`скачиваю ${url}`);
107
+
108
+ const response = await fetchImpl(url, { redirect: 'follow', signal: AbortSignal.timeout(timeoutMs) });
109
+ if (!response.ok || response.body === null) throw new Error(`загрузка ${url} не удалась: HTTP ${response.status}`);
110
+
111
+ const hash = createHash('sha256');
112
+ const stream = createWriteStream(partial);
113
+ let received = 0;
114
+ let lastReport = 0;
115
+ try {
116
+ for await (const chunk of Readable.fromWeb(response.body)) {
117
+ hash.update(chunk);
118
+ received += chunk.length;
119
+ if (!stream.write(chunk)) await new Promise((done) => stream.once('drain', done));
120
+ if (received - lastReport >= 10 * 1024 * 1024) {
121
+ lastReport = received;
122
+ log(`получено ${(received / 1024 / 1024).toFixed(1)} МБ`);
123
+ }
124
+ }
125
+ } finally {
126
+ await new Promise((done, fail) => stream.end((error) => (error ? fail(error) : done())));
127
+ }
128
+
129
+ const actual = hash.digest('hex');
130
+ if (expectSha256 !== undefined && actual !== expectSha256) {
131
+ rmSync(partial, { force: true });
132
+ throw new IntegrityError(`sha256 не совпал: ожидался ${expectSha256}, получен ${actual}`);
133
+ }
134
+
135
+ renameSync(partial, target);
136
+ log(`сохранено ${target} (${(received / 1024 / 1024).toFixed(1)} МБ)`);
137
+ return target;
138
+ }
139
+
140
+ /** Предсказуемый адрес ассета: имя файла детерминировано тегом релиза. */
141
+ export function predictAsset(tag) {
142
+ const version = String(tag).replace(/^v/, '');
143
+ const name = `bsl-language-server-${version}-exec.jar`;
144
+ return { tag, name, url: `${RELEASES_URL}/download/${tag}/${name}`, sha256: undefined, unverified: true };
145
+ }
146
+
147
+ /** Тег последнего релиза из обычной страницы: она редиректит на releases/tag/<тег>. */
148
+ async function latestTag(fetchImpl, log) {
149
+ const url = `${RELEASES_URL}/latest`;
150
+ log(`запрашиваю ${url}`);
151
+ const response = await fetchImpl(url, { redirect: 'follow', signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
152
+ if (!response.ok) throw new Error(`страница последнего релиза ${url}: HTTP ${response.status}`);
153
+ const location = String(response.url ?? response.headers?.get?.('location') ?? '');
154
+ const tag = location.split('/').filter(Boolean).pop();
155
+ if (tag === undefined || !/^v?\d/.test(tag)) throw new Error(`не удалось определить тег последнего релиза (${location || url})`);
156
+ return tag;
157
+ }
158
+
159
+ /**
160
+ * Разбирает релиз со страниц github.com — они не расходуют лимит API.
161
+ * На странице ассетов рядом с ссылкой лежит и sha256.
162
+ * @returns {{tag: string, name: string, url: string, sha256: string|undefined}}
163
+ */
164
+ export async function resolveReleaseViaHtml(fetchImpl, { tag, log = () => {} } = {}) {
165
+ const resolvedTag = tag ?? (await latestTag(fetchImpl, log));
166
+ const url = `${RELEASES_URL}/expanded_assets/${encodeURIComponent(resolvedTag)}`;
167
+ log(`запрашиваю ${url}`);
168
+ const response = await fetchImpl(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
169
+ if (!response.ok) throw new Error(`страница ассетов ${url}: HTTP ${response.status}`);
170
+
171
+ const html = await response.text();
172
+ const name = /href="[^"]*\/([^"/]+-exec\.jar)"/.exec(html)?.[1];
173
+ if (name === undefined) throw new Error(`на ${url} нет ассета *-exec.jar`);
174
+ const start = html.indexOf(name);
175
+ const sha256 = /sha256:([0-9a-f]{64})/i.exec(html.slice(start, start + 2000))?.[1];
176
+ return { tag: resolvedTag, name, url: `${RELEASES_URL}/download/${resolvedTag}/${name}`, sha256 };
177
+ }
178
+
179
+ /** Ассет из GitHub API: JSON содержит и тег, и digest. */
180
+ async function apiAsset(tag, { fetchImpl, log, token }) {
181
+ const url = tag === undefined ? `${API_REPO_URL}/releases/latest` : `${API_REPO_URL}/releases/tags/${tag}`;
182
+ return selectExecAsset(await fetchJson(fetchImpl, url, { log, token }));
183
+ }
184
+
185
+ /**
186
+ * Источники релиза по порядку. С токеном API первым, без токена — страницы
187
+ * релизов: лимит 60/ч на IP перестаёт быть условием инициализации.
188
+ * Функции не вызываются здесь: каждый следующий источник пробуется только
189
+ * после отказа предыдущего, поэтому успешная страница не тянет за собой API.
190
+ */
191
+ function assetSources(settings, deps) {
192
+ const { fetchImpl, log, token } = deps;
193
+ const tag = settings.version;
194
+ const viaHtml = () => resolveReleaseViaHtml(fetchImpl, { tag, log });
195
+ const viaApi = () => apiAsset(tag, deps);
196
+ const sources = token === undefined ? [viaHtml, viaApi] : [viaApi, viaHtml];
197
+ // Предсказуемый адрес — последний сетевой резерв, только если тег известен.
198
+ if (tag !== undefined) sources.push(async () => predictAsset(tag));
199
+ return sources;
200
+ }
201
+
202
+ /**
203
+ * Возвращает путь к jar: явный, из кэша или скачанный с GitHub.
204
+ * @param settings настройки из bslSettings().
205
+ * @param deps { fetchImpl, log, warn } — подменяются в тестах.
206
+ */
207
+ export async function ensureJar(settings, deps = {}) {
208
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
209
+ const log = deps.log ?? (() => {});
210
+ const warn = deps.warn ?? log;
211
+
212
+ if (settings.jar !== undefined) {
213
+ if (!existsSync(settings.jar)) throw new Error(`DSH_1C_BSL_LS_JAR указывает на ${settings.jar}, файла нет`);
214
+ return settings.jar;
215
+ }
216
+
217
+ if (settings.url !== undefined) {
218
+ const target = join(settings.cacheDir, basename(new URL(settings.url).pathname) || 'bsl-language-server-exec.jar');
219
+ if (existsSync(target)) return target;
220
+ return downloadToFile(settings.url, target, { fetchImpl, log });
221
+ }
222
+
223
+ if (!settings.downloadAllowed) {
224
+ throw new Error('BSL LS не настроен: задайте DSH_1C_BSL_LS_JAR (или URL) либо разрешите скачивание (DSH_1C_BSL_LS_DOWNLOAD не 0)');
225
+ }
226
+
227
+ const cached = cachedJars(settings.cacheDir);
228
+ // Зафиксированная версия: сначала кэш именно этой версии.
229
+ if (settings.version !== undefined) {
230
+ const target = join(settings.cacheDir, `bsl-language-server-${settings.version.replace(/^v/, '')}-exec.jar`);
231
+ if (existsSync(target)) return target;
232
+ } else if (cached.length > 0) {
233
+ // Версия не задана: годится любой уже скачанный jar.
234
+ log(`использую кэш ${cached[0]}`);
235
+ return cached[0];
236
+ }
237
+
238
+ const failures = [];
239
+ let rateLimited;
240
+ const text = (error) => (error instanceof Error ? error.message : String(error));
241
+
242
+ for (const source of assetSources(settings, { fetchImpl, log, token: settings.githubToken })) {
243
+ let asset;
244
+ try {
245
+ asset = await source();
246
+ } catch (error) {
247
+ if (error instanceof RateLimitError) rateLimited = error;
248
+ failures.push(error);
249
+ warn(`источник релиза не сработал: ${text(error)}`);
250
+ continue;
251
+ }
252
+
253
+ const target = join(settings.cacheDir, asset.name);
254
+ if (existsSync(target)) return target;
255
+ if (asset.unverified === true) {
256
+ warn(`sha256 для ${asset.name} не сверен: страницы релиза недоступны, скачиваю по предсказуемому адресу`);
257
+ }
258
+ try {
259
+ return await downloadToFile(asset.url, target, { fetchImpl, log, expectSha256: asset.sha256 });
260
+ } catch (error) {
261
+ // Подмена файла не лечится повтором без сверки — сразу наверх.
262
+ if (error instanceof IntegrityError) throw error;
263
+ failures.push(error);
264
+ warn(`не удалось скачать ${asset.name}: ${text(error)}`);
265
+ }
266
+ }
267
+
268
+ // Любая версия из кэша лучше, чем немигающая инициализация BSL LS.
269
+ if (cached.length > 0) {
270
+ const reason = rateLimited ?? failures[failures.length - 1];
271
+ warn(`релиз недоступен (${reason === undefined ? 'нет источников' : text(reason)}); использую кэш ${basename(cached[0])}`);
272
+ return cached[0];
273
+ }
274
+
275
+ if (rateLimited !== undefined) throw rateLimited;
276
+ throw new Error(`не удалось получить релиз BSL LS: ${failures.map(text).join('; ')}`);
277
+ }
278
+
279
+ /** Тело ответа API в виде текста ошибки, если оно есть. */
280
+ async function responseMessage(response) {
281
+ try {
282
+ const body = await response.json();
283
+ return typeof body?.message === 'string' ? body.message : '';
284
+ } catch {
285
+ return '';
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Запрос к GitHub API. Токен уходит только на api.github.com — ни на страницы,
291
+ * ни на скачивание ассета. Лимит распознаётся и превращается в понятную ошибку.
292
+ */
293
+ export async function fetchJson(fetchImpl, url, { log = () => {}, token } = {}) {
294
+ log(`запрашиваю ${url}`);
295
+ const headers = { accept: 'application/vnd.github+json', 'user-agent': 'dsh-edt-projects' };
296
+ if (token !== undefined && new URL(url).host === 'api.github.com') headers.authorization = `Bearer ${token}`;
297
+
298
+ const response = await fetchImpl(url, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
299
+ if (response.ok) return response.json();
300
+
301
+ const message = await responseMessage(response);
302
+ const header = (name) => response.headers?.get?.(name);
303
+ const resetSeconds = Number(header('x-ratelimit-reset'));
304
+ const resetAt = Number.isFinite(resetSeconds) && resetSeconds > 0 ? resetSeconds : undefined;
305
+ const limited = response.status === 429 || (response.status === 403 && (header('x-ratelimit-remaining') === '0' || /rate limit/i.test(message) || resetAt !== undefined));
306
+ if (limited) {
307
+ const until = resetAt === undefined ? '' : ` Сброс лимита не раньше ${new Date(resetAt * 1000).toISOString()}.`;
308
+ throw new RateLimitError(
309
+ `GitHub API исчерпал лимит запросов (без токена — 60/ч на IP).${until}` +
310
+ ' Варианты: задать DSH_1C_BSL_LS_GITHUB_TOKEN, DSH_1C_BSL_LS_VERSION (скачивание без API), DSH_1C_BSL_LS_URL или DSH_1C_BSL_LS_JAR.',
311
+ { resetAt, status: response.status },
312
+ );
313
+ }
314
+ throw new Error(`GitHub API ${url}: HTTP ${response.status}${message === '' ? '' : ` — ${message}`}`);
315
+ }
316
+
317
+ /**
318
+ * Собирает параметры монтирования BSL LS.
319
+ * @returns {{transport: 'stdio', command: string, args: string[], cwd: string} |
320
+ * {transport: 'streamable-http', url: string}}
321
+ */
322
+ export async function resolveBsl(settings, deps = {}) {
323
+ if (settings.mcpUrl !== undefined) return { transport: 'streamable-http', url: settings.mcpUrl };
324
+ const jar = await ensureJar(settings, deps);
325
+ return { transport: 'stdio', command: settings.java, args: ['-jar', jar, 'mcp'], cwd: settings.cwd };
326
+ }
327
+
328
+ /** Можно ли вообще поднять сервер: без скачивания, пути и кэша он «не настроен». */
329
+ export function bslAvailable(settings) {
330
+ if (settings.mcpUrl !== undefined) return true;
331
+ if (settings.jar !== undefined || settings.url !== undefined) return true;
332
+ if (settings.downloadAllowed) return true;
333
+ // Кэш остаётся рабочим источником и при DSH_1C_BSL_LS_DOWNLOAD=0.
334
+ return settings.cacheDir !== undefined && cachedJars(settings.cacheDir).length > 0;
335
+ }
336
+
337
+ /** Свободный порт на loopback. */
338
+ export function freePort() {
339
+ return new Promise((resolvePort, reject) => {
340
+ const probe = createServer();
341
+ probe.once('error', reject);
342
+ probe.listen(0, '127.0.0.1', () => {
343
+ const { port: found } = probe.address();
344
+ probe.close(() => resolvePort(found));
345
+ });
346
+ });
347
+ }
348
+
349
+ /** LSP-сообщение с заголовком Content-Length. */
350
+ export function lspFrame(message) {
351
+ const body = JSON.stringify(message);
352
+ return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
353
+ }
354
+
355
+ /** Ждёт, пока на порту начнёт приниматься TCP. */
356
+ export async function waitForPort(port, { timeoutMs = 180_000, intervalMs = 500 } = {}) {
357
+ const deadline = Date.now() + timeoutMs;
358
+ for (;;) {
359
+ const connected = await new Promise((resolveConnect) => {
360
+ const socket = connect({ host: '127.0.0.1', port });
361
+ const settle = (ok) => {
362
+ socket.destroy();
363
+ resolveConnect(ok);
364
+ };
365
+ socket.setTimeout(1000);
366
+ socket.once('connect', () => settle(true));
367
+ socket.once('timeout', () => settle(false));
368
+ socket.once('error', () => settle(false));
369
+ });
370
+ if (connected) return;
371
+ if (Date.now() > deadline) throw new Error(`BSL LS не открыл порт ${port} за ${Math.round(timeoutMs / 1000)} с`);
372
+ await new Promise((done) => setTimeout(done, intervalMs));
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Поднимает BSL LS в гибридном режиме: LSP по stdio объявляет рабочие
378
+ * пространства (workspace folders), MCP открывается по Streamable HTTP на том
379
+ * же процессе. Так BSL LS получает корни, которых не умеет передавать
380
+ * dsh-mcp-client (он не объявляет capability roots).
381
+ *
382
+ * @param options.jar путь к jar, options.java команда java, options.root корень
383
+ * @returns {{url: string, root: string, stop: () => Promise<void>}}
384
+ */
385
+ export async function startBslSupervisor({ jar, java = 'java', root, port, log = () => {}, timeoutMs = 180_000 }) {
386
+ // Каталог корня должен существовать: иначе spawn падает с ENOENT, и вместо
387
+ // понятной ошибки получается долгое ожидание порта.
388
+ if (!existsSync(root)) throw new Error(`каталог рабочего пространства не найден: ${root}`);
389
+
390
+ const actualPort = port === undefined ? await freePort() : Number(port);
391
+ log(`запускаю BSL LS: ${java} -jar ${basename(jar)} lsp --mcp --server.port=${actualPort} (root ${root})`);
392
+
393
+ const child = (() => {
394
+ try {
395
+ return spawn(java, ['-jar', jar, 'lsp', '--mcp', `--server.port=${actualPort}`], {
396
+ cwd: root,
397
+ stdio: ['pipe', 'pipe', 'pipe'],
398
+ });
399
+ } catch (error) {
400
+ // Windows может бросить синхронно (нет прав на создание процесса).
401
+ throw new Error(`BSL LS не удалось запустить (${java}): ${error.message}`);
402
+ }
403
+ })();
404
+
405
+ // Ошибка запуска и ранний выход должны прерывать ожидание, а не висеть в нём.
406
+ let startupFailure = null;
407
+ const failed = new Promise((_, reject) => {
408
+ child.once('error', (error) => {
409
+ startupFailure = error.code === 'ENOENT' ? `команда ${java} не найдена` : error.message;
410
+ reject(new Error(`BSL LS не запустился: ${startupFailure}`));
411
+ });
412
+ child.once('exit', (code, signal) => {
413
+ startupFailure = `процесс завершился (${signal ?? code})`;
414
+ reject(new Error(`BSL LS завершился при запуске: ${signal ?? code}`));
415
+ });
416
+ });
417
+ failed.catch(() => {});
418
+
419
+ child.stderr.on('data', (chunk) => {
420
+ const text = String(chunk);
421
+ if (/ERROR|Exception|WARN.*roots/i.test(text)) log(text.trim().slice(0, 400));
422
+ });
423
+
424
+ const rootUri = pathToFileURL(root).href;
425
+ child.stdin.write(
426
+ lspFrame({
427
+ jsonrpc: '2.0',
428
+ id: 1,
429
+ method: 'initialize',
430
+ params: {
431
+ processId: process.pid,
432
+ rootUri,
433
+ workspaceFolders: [{ uri: rootUri, name: basename(root) || 'workspace' }],
434
+ capabilities: {},
435
+ },
436
+ }),
437
+ );
438
+ child.stdin.write(lspFrame({ jsonrpc: '2.0', method: 'initialized', params: {} }));
439
+
440
+ try {
441
+ await Promise.race([waitForPort(actualPort, { timeoutMs }), failed]);
442
+ } catch (error) {
443
+ child.kill();
444
+ throw error;
445
+ }
446
+
447
+ return {
448
+ url: `http://127.0.0.1:${actualPort}/mcp`,
449
+ root,
450
+ stop: async () => {
451
+ child.kill();
452
+ },
453
+ };
454
+ }
455
+
@@ -0,0 +1,67 @@
1
+ // Создание и обновление файла настроек dsh-1c.json.
2
+ //
3
+ // Существующие значения не перезаписываются: init отказывается работать при
4
+ // наличии файла, upgrade только дописывает недостающие ключи. Запись атомарная.
5
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
6
+
7
+ import { DEFAULTS, planUpgrade, stringifyConfig, validateConfig } from './schema.mjs';
8
+
9
+ /** Читает файл настроек; отсутствие — не ошибка. */
10
+ export function readExisting(path) {
11
+ if (!existsSync(path)) return { exists: false };
12
+ try {
13
+ const config = JSON.parse(readFileSync(path, 'utf8'));
14
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
15
+ return { exists: true, error: `${path}: ожидался JSON-объект` };
16
+ }
17
+ return { exists: true, config };
18
+ } catch (error) {
19
+ return { exists: true, error: `${path}: ${error.message}` };
20
+ }
21
+ }
22
+
23
+ /** Атомарная запись: временный файл рядом, затем rename. */
24
+ export function writeAtomic(path, text) {
25
+ const tmp = `${path}.tmp`;
26
+ try {
27
+ writeFileSync(tmp, text, 'utf8');
28
+ renameSync(tmp, path);
29
+ } catch (error) {
30
+ rmSync(tmp, { force: true });
31
+ throw error;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Создаёт файл со значениями по умолчанию.
37
+ * @returns {{ status: 'created'|'exists'|'error', path: string, keys?: string[], error?: string }}
38
+ */
39
+ export function initFile(path) {
40
+ const existing = readExisting(path);
41
+ if (existing.error !== undefined) return { status: 'error', path, error: existing.error };
42
+ if (existing.exists) return { status: 'exists', path };
43
+
44
+ writeAtomic(path, stringifyConfig(DEFAULTS));
45
+ return { status: 'created', path, keys: Object.keys(DEFAULTS) };
46
+ }
47
+
48
+ /**
49
+ * Дописывает недостающие ключи. При `dryRun` файл не трогается.
50
+ * @returns {{ status: 'updated'|'noop'|'error'|'dry-run', path: string,
51
+ * added: string[], warnings: string[], errors: string[], config?: object, error?: string }}
52
+ */
53
+ export function upgradeFile(path, { dryRun = false } = {}) {
54
+ const existing = readExisting(path);
55
+ if (existing.error !== undefined) return { status: 'error', path, error: existing.error, added: [], warnings: [], errors: [] };
56
+
57
+ const base = existing.exists ? existing.config : {};
58
+ const plan = planUpgrade(base);
59
+ const { errors, warnings } = validateConfig(plan.config);
60
+
61
+ const result = { path, added: plan.added, warnings, errors, config: plan.config, version: plan.version };
62
+ if (plan.added.length === 0) return { status: 'noop', ...result };
63
+ if (dryRun) return { status: 'dry-run', ...result };
64
+
65
+ writeAtomic(path, stringifyConfig(plan.config));
66
+ return { status: 'updated', ...result };
67
+ }