dsh-edt-projects 0.10.0 → 0.12.0

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.
@@ -0,0 +1,776 @@
1
+ // Окружение 1C:EDT для git worktree: настройки, файлы состояния, порт,
2
+ // установка EDT и проверка готовности.
3
+ //
4
+ // Модуль намеренно не запускает процессы: подъёмом занимается
5
+ // workspace-run.mjs, а плагин (lib/index.js) только читает состояние и решает,
6
+ // когда его запустить. Так все функции здесь можно тестировать на временных
7
+ // каталогах и с подменёнными зависимостями.
8
+ //
9
+ // Схема `build/` повторяет проверенную opencode-раскладку, поэтому окружение,
10
+ // поднятое opencode в том же worktree, плагин принимает как готовое.
11
+ import { execFileSync } from 'node:child_process';
12
+ import {
13
+ existsSync,
14
+ mkdirSync,
15
+ readFileSync,
16
+ readdirSync,
17
+ readlinkSync,
18
+ renameSync,
19
+ rmSync,
20
+ statSync,
21
+ writeFileSync,
22
+ } from 'node:fs';
23
+ import { createServer, connect } from 'node:net';
24
+ import path from 'node:path';
25
+
26
+ import { DEFAULTS } from './schema.mjs';
27
+
28
+ /** Корень окружения внутри worktree. */
29
+ export const BUILD_DIR = 'build';
30
+
31
+ /** Имена файлов состояния внутри `<root>/build`. */
32
+ export const FILE_NAMES = {
33
+ port: 'edt-mcp-port',
34
+ pid: 'edt-mcp.pid',
35
+ ready: 'edt-mcp-ready',
36
+ failed: 'edt-mcp-failed',
37
+ imported: 'edt-imported',
38
+ progress: 'init-progress.json',
39
+ script: 'import.script',
40
+ snapshot: 'workspace.json',
41
+ lock: 'init.lock',
42
+ runLock: 'init-run.lock',
43
+ logs: 'logs',
44
+ };
45
+
46
+ /** Платформы, на которых ищем установленный EDT по умолчанию. */
47
+ const EDT_ROOTS = {
48
+ win32: ['C:\\Program Files\\1C\\1CE\\components', 'C:\\Program Files (x86)\\1C\\1CE\\components'],
49
+ linux: ['/opt/1C/1CE/components'],
50
+ };
51
+
52
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
53
+ const text = (value) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined);
54
+
55
+ /** Путь файла состояния. */
56
+ export const filePath = (buildDir, key) => path.join(buildDir, FILE_NAMES[key]);
57
+
58
+ /** Путь к prefs MCP-сервера EDT внутри workspace. */
59
+ export function prefsPath(buildDir) {
60
+ return path.join(
61
+ buildDir,
62
+ 'ws',
63
+ '.metadata',
64
+ '.plugins',
65
+ 'org.eclipse.core.runtime',
66
+ '.settings',
67
+ 'com.ditrix.edt.mcp.server.prefs',
68
+ );
69
+ }
70
+
71
+ /** Каталог, где EDT хранит зарегистрированные проекты workspace. */
72
+ export function registeredProjectsDir(buildDir) {
73
+ return path.join(buildDir, 'ws', '.metadata', '.plugins', 'org.eclipse.core.resources', '.projects');
74
+ }
75
+
76
+ /**
77
+ * Настройки окружения с учётом переменных DSH_1C_EDT_HOME / DSH_1C_EDT_IMPORT.
78
+ * @param raw секция workspace из настроек плагина.
79
+ * @param env окружение процесса.
80
+ */
81
+ export function normalizeWorkspaceSettings(raw = {}, env = process.env) {
82
+ const defaults = DEFAULTS.workspace;
83
+ const value = isObject(raw) ? raw : {};
84
+ const pick = (key, fallback) => (value[key] === undefined ? fallback : value[key]);
85
+ const positive = (candidate, fallback) => (Number.isFinite(candidate) && candidate > 0 ? Math.floor(candidate) : fallback);
86
+ const port = (candidate, fallback) =>
87
+ Number.isInteger(candidate) && candidate >= 1 && candidate <= 65535 ? candidate : fallback;
88
+
89
+ let importMode = pick('import', defaults.import);
90
+ if (importMode !== true && importMode !== false) importMode = 'auto';
91
+ const envImport = env?.DSH_1C_EDT_IMPORT;
92
+ if (typeof envImport === 'string' && envImport.trim() !== '') {
93
+ const lowered = envImport.trim().toLowerCase();
94
+ if (['1', 'true', 'yes', 'on'].includes(lowered)) importMode = true;
95
+ else if (['0', 'false', 'no', 'off'].includes(lowered)) importMode = false;
96
+ }
97
+
98
+ const ports = isObject(value.ports) ? value.ports : {};
99
+ const min = port(ports.min, defaults.ports.min);
100
+ const max = port(ports.max, defaults.ports.max);
101
+
102
+ return {
103
+ enabled: pick('enabled', defaults.enabled) !== false,
104
+ root: text(value.root) ?? '',
105
+ home: {
106
+ win32: text(value.home?.win32) ?? '',
107
+ linux: text(value.home?.linux) ?? '',
108
+ },
109
+ vm: text(value.vm) ?? '',
110
+ xmxIde: text(value.xmxIde) ?? defaults.xmxIde,
111
+ xmxImport: text(value.xmxImport) ?? defaults.xmxImport,
112
+ import: importMode,
113
+ importTimeout: positive(value.importTimeout, defaults.importTimeout),
114
+ startTimeout: positive(value.startTimeout, defaults.startTimeout),
115
+ projects: Array.isArray(value.projects) ? value.projects.filter((item) => text(item) !== undefined).map((item) => item.trim()) : [],
116
+ ports: min <= max ? { min, max } : { min: Math.min(min, max), max: Math.max(min, max) },
117
+ stopOnSessionEnd: pick('stopOnSessionEnd', defaults.stopOnSessionEnd) !== false,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Куда поднимать окружение для этой сессии.
123
+ *
124
+ * Автостарт включается только в linked worktree: основной checkout работает
125
+ * с уже запущенным вручную EDT. Если в task space несколько включённых
126
+ * репозиториев, берётся первый (отсортированный) и выдаётся предупреждение;
127
+ * `workspace.root` позволяет выбрать явно.
128
+ */
129
+ export function resolveWorkspaceTarget({ cwd, config, repositories = [], env = process.env } = {}) {
130
+ const settings = normalizeWorkspaceSettings(config?.workspace, env);
131
+ if (!settings.enabled) return { state: 'off', reason: 'disabled', settings };
132
+ if (!Array.isArray(repositories) || repositories.length === 0) {
133
+ return {
134
+ state: 'error',
135
+ reason: 'no-repository',
136
+ settings,
137
+ message: 'каталог сессии не является git-репозиторием и не содержит репозиториев первого уровня',
138
+ };
139
+ }
140
+
141
+ const worktrees = repositories.filter((repo) => repo?.isWorktree === true && typeof repo.root === 'string');
142
+ if (worktrees.length === 0) return { state: 'off', reason: 'main-checkout', settings };
143
+
144
+ const warnings = [];
145
+ const repo = worktrees[0];
146
+ if (worktrees.length > 1) {
147
+ warnings.push(
148
+ `несколько worktree (${worktrees.map((item) => item.root).join(', ')}); выбран ${repo.root}. Укажите workspace.root, чтобы выбрать другой.`,
149
+ );
150
+ }
151
+
152
+ const configured = settings.root;
153
+ const root =
154
+ configured === ''
155
+ ? repo.root
156
+ : path.isAbsolute(configured)
157
+ ? path.normalize(configured)
158
+ : path.resolve(repo.root, configured);
159
+ const buildDir = path.join(root, BUILD_DIR);
160
+
161
+ return {
162
+ state: 'ok',
163
+ settings,
164
+ repo,
165
+ mainRoot: repo.mainRoot,
166
+ root,
167
+ buildDir,
168
+ wsDir: path.join(buildDir, 'ws'),
169
+ isWorktree: true,
170
+ rootSource: configured === '' ? 'repo' : 'config',
171
+ warnings,
172
+ };
173
+ }
174
+
175
+ // --- проекты EDT ------------------------------------------------------------
176
+
177
+ /** Есть ли в каталоге Eclipse-проект. */
178
+ export function hasProjectFile(dir) {
179
+ try {
180
+ return statSync(path.join(dir, '.project')).isFile();
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+
186
+ const hasWildcard = (segment) => segment.includes('*') || segment.includes('?');
187
+
188
+ function matchSegment(name, segment) {
189
+ if (!hasWildcard(segment)) return name.toLowerCase() === segment.toLowerCase();
190
+ const pattern = segment
191
+ .split('')
192
+ .map((char) => {
193
+ if (char === '*') return '.*';
194
+ if (char === '?') return '.';
195
+ return char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
196
+ })
197
+ .join('');
198
+ return new RegExp(`^${pattern}$`, 'i').test(name);
199
+ }
200
+
201
+ function walkPattern(dir, segments, out) {
202
+ if (segments.length === 0) {
203
+ out.push(dir);
204
+ return;
205
+ }
206
+ const [head, ...rest] = segments;
207
+ if (head === '') {
208
+ walkPattern(dir, rest, out);
209
+ return;
210
+ }
211
+ if (head === '..') {
212
+ walkPattern(path.dirname(dir), rest, out);
213
+ return;
214
+ }
215
+ if (!hasWildcard(head)) {
216
+ const next = path.join(dir, head);
217
+ if (existsSync(next)) walkPattern(next, rest, out);
218
+ return;
219
+ }
220
+ let entries;
221
+ try {
222
+ entries = readdirSync(dir, { withFileTypes: true });
223
+ } catch {
224
+ return;
225
+ }
226
+ for (const entry of entries) {
227
+ if (!entry.isDirectory() || !matchSegment(entry.name, head)) continue;
228
+ walkPattern(path.join(dir, entry.name), rest, out);
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Разворачивает шаблон EDT-проекта в каталоги с `.project`.
234
+ * Поддерживает `*`/`?` в любом сегменте и абсолютные пути.
235
+ */
236
+ export function expandProjectPattern(root, pattern) {
237
+ const raw = String(pattern ?? '').trim();
238
+ if (raw === '') return [];
239
+ const isAbsolute = path.isAbsolute(raw);
240
+ const absolute = isAbsolute ? path.resolve(raw) : undefined;
241
+ const startDir = isAbsolute ? path.parse(absolute).root : root;
242
+ const relative = isAbsolute ? path.relative(startDir, absolute) : raw;
243
+ const segments = relative.split(/[\\/]+/).filter((segment) => segment !== '' && segment !== '.');
244
+ const out = [];
245
+ walkPattern(startDir, segments, out);
246
+ return out.filter(hasProjectFile);
247
+ }
248
+
249
+ const dedupePaths = (paths) => {
250
+ const seen = new Set();
251
+ const out = [];
252
+ for (const item of paths) {
253
+ const key = path.resolve(item);
254
+ if (seen.has(key)) continue;
255
+ seen.add(key);
256
+ out.push(key);
257
+ }
258
+ return out.sort((left, right) => left.localeCompare(right, undefined, { sensitivity: 'base' }));
259
+ };
260
+
261
+ /**
262
+ * Проекты EDT для импорта: шаблоны из настроек либо автопоиск каталогов
263
+ * с `.project` до трёх уровней (кроме build, node_modules и скрытых).
264
+ */
265
+ export function discoverProjects(root, patterns = [], { maxDepth = 3 } = {}) {
266
+ if (Array.isArray(patterns) && patterns.length > 0) {
267
+ const matched = [];
268
+ for (const pattern of patterns) matched.push(...expandProjectPattern(root, pattern));
269
+ return dedupePaths(matched);
270
+ }
271
+
272
+ const found = [];
273
+ const queue = [{ dir: path.resolve(root), depth: 0 }];
274
+ while (queue.length > 0) {
275
+ const { dir, depth } = queue.shift();
276
+ if (hasProjectFile(dir)) {
277
+ found.push(dir);
278
+ continue;
279
+ }
280
+ if (depth >= maxDepth) continue;
281
+ let entries;
282
+ try {
283
+ entries = readdirSync(dir, { withFileTypes: true });
284
+ } catch {
285
+ continue;
286
+ }
287
+ for (const entry of entries) {
288
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'build' || entry.name === 'node_modules') continue;
289
+ queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
290
+ }
291
+ }
292
+ return [...new Set(found)].sort((left, right) => left.localeCompare(right, undefined, { sensitivity: 'base' }));
293
+ }
294
+
295
+ /** Текст скрипта импорта для `1cedtcli -file`. */
296
+ export function importScriptText(projects) {
297
+ const lines = (projects ?? [])
298
+ .filter((item) => typeof item === 'string' && item.trim() !== '')
299
+ .map((item) => `import --project "${item}"`);
300
+ return lines.length > 0 ? `${lines.join('\n')}\n` : '';
301
+ }
302
+
303
+ // --- prefs EDT-MCP ----------------------------------------------------------
304
+
305
+ /** Дописывает значения в prefs, сохраняя остальные ключи. */
306
+ export function mergePrefsText(current, values = {}) {
307
+ const map = new Map();
308
+ for (const line of String(current ?? '').split(/\r?\n/)) {
309
+ const index = line.indexOf('=');
310
+ if (index > 0) map.set(line.slice(0, index), line.slice(index + 1));
311
+ }
312
+ for (const [key, value] of Object.entries(values)) map.set(key, String(value));
313
+ let out = 'eclipse.preferences.version=1\n';
314
+ for (const [key, value] of map) {
315
+ if (key === 'eclipse.preferences.version') continue;
316
+ out += `${key}=${value}\n`;
317
+ }
318
+ return out;
319
+ }
320
+
321
+ /** prefs, включающие MCP-сервер EDT на нужном порту при старте. */
322
+ export function writePrefs(buildDir, port) {
323
+ const file = prefsPath(buildDir);
324
+ mkdirSync(path.dirname(file), { recursive: true });
325
+ const current = existsSync(file) ? readFileSync(file, 'utf8') : '';
326
+ const next = mergePrefsText(current, { mcpServerAutoStart: 'true', mcpServerPort: port });
327
+ atomicWrite(file, next);
328
+ return file;
329
+ }
330
+
331
+ // --- порт -------------------------------------------------------------------
332
+
333
+ /** Свободен ли порт на loopback. */
334
+ export function isPortFree(port, host = '127.0.0.1') {
335
+ return new Promise((resolve) => {
336
+ const server = createServer();
337
+ server.once('error', () => resolve(false));
338
+ server.once('listening', () => server.close(() => resolve(true)));
339
+ server.listen(port, host);
340
+ });
341
+ }
342
+
343
+ /**
344
+ * Случайный свободный порт из диапазона, не занятый самим плагином.
345
+ * @param range { min, max }
346
+ * @param deps.isFree проверка занятости (для тестов).
347
+ * @param deps.used уже использованные порты.
348
+ */
349
+ export async function pickFreePort(range, deps = {}) {
350
+ const probe = deps.isFree ?? isPortFree;
351
+ const used = deps.used ?? new Set();
352
+ const random = deps.random ?? Math.random;
353
+ const min = Number(range?.min);
354
+ const max = Number(range?.max);
355
+ if (!Number.isInteger(min) || !Number.isInteger(max) || min < 1 || max < min) {
356
+ throw new Error(`некорректный диапазон портов: ${range?.min}..${range?.max}`);
357
+ }
358
+ const span = max - min + 1;
359
+ for (let attempt = 0; attempt < span; attempt++) {
360
+ const port = min + Math.floor(random() * span);
361
+ if (used.has(port)) continue;
362
+ if (await probe(port)) return port;
363
+ used.add(port);
364
+ }
365
+ throw new Error(`нет свободного порта в диапазоне ${min}..${max}`);
366
+ }
367
+
368
+ /** Порт, занятый нашим процессом (для проверки готовности). */
369
+ export function isPortOpen(port, host = '127.0.0.1', timeoutMs = 1000) {
370
+ return new Promise((resolve) => {
371
+ const socket = connect({ host, port });
372
+ const done = (ok) => {
373
+ socket.destroy();
374
+ resolve(ok);
375
+ };
376
+ socket.setTimeout(timeoutMs);
377
+ socket.once('connect', () => done(true));
378
+ socket.once('timeout', () => done(false));
379
+ socket.once('error', () => done(false));
380
+ });
381
+ }
382
+
383
+ // --- файлы состояния --------------------------------------------------------
384
+
385
+ /** Атомарная запись: временный файл рядом и rename. */
386
+ export function atomicWrite(file, content) {
387
+ mkdirSync(path.dirname(file), { recursive: true });
388
+ const tmp = `${file}.tmp`;
389
+ try {
390
+ writeFileSync(tmp, content, 'utf8');
391
+ renameSync(tmp, file);
392
+ } catch (error) {
393
+ try {
394
+ rmSync(tmp, { force: true });
395
+ } catch {
396
+ // временный файл мог не создаться — это не мешает исходной ошибке
397
+ }
398
+ throw error;
399
+ }
400
+ }
401
+
402
+ const readText = (file) => {
403
+ try {
404
+ return readFileSync(file, 'utf8');
405
+ } catch {
406
+ return undefined;
407
+ }
408
+ };
409
+
410
+ const readJson = (file) => {
411
+ const content = readText(file);
412
+ if (content === undefined) return undefined;
413
+ try {
414
+ return JSON.parse(content);
415
+ } catch {
416
+ return undefined;
417
+ }
418
+ };
419
+
420
+ /** Порт MCP-сервера EDT или null. */
421
+ export function readPort(buildDir) {
422
+ const content = readText(filePath(buildDir, 'port'));
423
+ if (content === undefined) return null;
424
+ const value = Number.parseInt(content.trim(), 10);
425
+ return Number.isInteger(value) && value > 0 && value <= 65535 ? value : null;
426
+ }
427
+
428
+ export function writePort(buildDir, port) {
429
+ atomicWrite(filePath(buildDir, 'port'), String(port));
430
+ }
431
+
432
+ /** `{ pid, startedAt }` процесса EDT или null. */
433
+ export function readPid(buildDir) {
434
+ const value = readJson(filePath(buildDir, 'pid'));
435
+ const pid = Number.parseInt(value?.pid, 10);
436
+ if (!Number.isInteger(pid) || pid <= 0) return null;
437
+ return { pid, startedAt: typeof value?.startedAt === 'string' ? value.startedAt : undefined };
438
+ }
439
+
440
+ export function writePid(buildDir, pid, now = new Date()) {
441
+ atomicWrite(filePath(buildDir, 'pid'), JSON.stringify({ pid, startedAt: now.toISOString() }));
442
+ }
443
+
444
+ /** Отметка времени из файла-маркера. */
445
+ export function readStamp(buildDir, key) {
446
+ return readText(filePath(buildDir, key))?.trim() || undefined;
447
+ }
448
+
449
+ export function writeStamp(buildDir, key, now = new Date()) {
450
+ atomicWrite(filePath(buildDir, key), now.toISOString());
451
+ }
452
+
453
+ export function removeStamp(buildDir, key) {
454
+ try {
455
+ rmSync(filePath(buildDir, key), { force: true });
456
+ } catch {
457
+ // маркера могло не быть
458
+ }
459
+ }
460
+
461
+ /** Что импортировали в workspace: `{ projects, at }` или null. */
462
+ export function readImported(buildDir) {
463
+ return readJson(filePath(buildDir, 'imported')) ?? null;
464
+ }
465
+
466
+ export function writeImported(buildDir, projects, now = new Date()) {
467
+ atomicWrite(filePath(buildDir, 'imported'), JSON.stringify({ projects, at: now.toISOString() }));
468
+ }
469
+
470
+ /** Прогресс подъёма: `{ startedAt, edt: { phase, since, updated, detail } }`. */
471
+ export function readProgress(buildDir) {
472
+ return readJson(filePath(buildDir, 'progress')) ?? null;
473
+ }
474
+
475
+ export function writeProgress(buildDir, kind, phase, detail) {
476
+ const prev = readProgress(buildDir) ?? { startedAt: new Date().toISOString() };
477
+ const now = new Date().toISOString();
478
+ const was = prev[kind];
479
+ prev[kind] = { phase, since: was?.phase === phase && was.since ? was.since : now, updated: now };
480
+ if (detail !== undefined) prev[kind].detail = detail;
481
+ atomicWrite(filePath(buildDir, 'progress'), `${JSON.stringify(prev, null, 2)}\n`);
482
+ }
483
+
484
+ export function clearProgress(buildDir) {
485
+ for (const file of [filePath(buildDir, 'progress'), `${filePath(buildDir, 'progress')}.tmp`]) {
486
+ try {
487
+ rmSync(file, { force: true });
488
+ } catch {
489
+ // прогресса могло не быть
490
+ }
491
+ }
492
+ }
493
+
494
+ /** Последняя непустая строка файла, обрезанная до maxLen. */
495
+ export function tailFile(file, maxLen = 200) {
496
+ const content = readText(file);
497
+ if (content === undefined) return null;
498
+ const lines = content.split(/\r?\n/).filter((line) => line.trim() !== '');
499
+ return lines.length === 0 ? null : lines[lines.length - 1].slice(0, maxLen);
500
+ }
501
+
502
+ /** Снимок настроек окружения — для диагностики и фонового процесса. */
503
+ export function buildWorkspaceSnapshot({ root, buildDir, wsDir, mainRoot, settings, projects, now = new Date() }) {
504
+ return {
505
+ version: 1,
506
+ root,
507
+ buildDir,
508
+ wsDir,
509
+ mainRoot,
510
+ createdAt: now.toISOString(),
511
+ settings,
512
+ projects,
513
+ };
514
+ }
515
+
516
+ export function writeWorkspaceSnapshot(buildDir, snapshot) {
517
+ atomicWrite(filePath(buildDir, 'snapshot'), `${JSON.stringify(snapshot, null, 2)}\n`);
518
+ }
519
+
520
+ export function readWorkspaceSnapshot(buildDir) {
521
+ return readJson(filePath(buildDir, 'snapshot'));
522
+ }
523
+
524
+ /**
525
+ * Снимок для конкретного worktree: пути, настройки и найденные проекты EDT.
526
+ * Пишется один раз перед подъёмом; фоновый процесс читает его, а не настройки
527
+ * плагина — так окружение поднимается ровно по тому, что было в момент старта.
528
+ */
529
+ export function prepareWorkspaceSnapshot({ root, mainRoot, settings, patterns, now = new Date() }) {
530
+ const buildDir = path.join(root, BUILD_DIR);
531
+ const projects = discoverProjects(root, patterns ?? settings?.projects ?? []);
532
+ return buildWorkspaceSnapshot({
533
+ root,
534
+ buildDir,
535
+ wsDir: path.join(buildDir, 'ws'),
536
+ mainRoot,
537
+ settings,
538
+ projects,
539
+ now,
540
+ });
541
+ }
542
+
543
+ // --- процессы ---------------------------------------------------------------
544
+
545
+ /** Имя образа процесса на Windows (`1cedt.exe`) или null. */
546
+ export function parseTasklistImage(output) {
547
+ const line = String(output ?? '')
548
+ .split(/\r?\n/)
549
+ .find((item) => item.trim().startsWith('"'));
550
+ if (line === undefined) return null;
551
+ const match = /^"([^"]+)"/.exec(line);
552
+ return match ? match[1].toLowerCase() : null;
553
+ }
554
+
555
+ export function processImage(pid, { platform = process.platform, run = execFileSync } = {}) {
556
+ try {
557
+ if (platform === 'win32') {
558
+ const output = run('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], { encoding: 'utf8', windowsHide: true });
559
+ return parseTasklistImage(output);
560
+ }
561
+ return path.basename(readlinkSync(`/proc/${pid}/exe`)).toLowerCase();
562
+ } catch {
563
+ return null;
564
+ }
565
+ }
566
+
567
+ /** Жив ли процесс; при указании образов — того ли он типа. */
568
+ export function pidAlive(pid, images = null, deps = {}) {
569
+ if (!Number.isInteger(pid) || pid <= 0) return false;
570
+ try {
571
+ process.kill(pid, 0);
572
+ } catch {
573
+ return false;
574
+ }
575
+ if (!Array.isArray(images) || images.length === 0) return true;
576
+ const image = (deps.processImage ?? processImage)(pid, deps);
577
+ if (!image) return true;
578
+ return images.some((candidate) => image.includes(String(candidate).toLowerCase()));
579
+ }
580
+
581
+ /** Останавливает дерево процессов: сначала мягко, потом принудительно. */
582
+ export function killTree(pid, { force = false, platform = process.platform, run = execFileSync, kill = process.kill } = {}) {
583
+ try {
584
+ if (platform === 'win32') {
585
+ const args = force ? ['/PID', String(pid), '/T', '/F'] : ['/PID', String(pid), '/T'];
586
+ run('taskkill', args, { stdio: 'ignore', windowsHide: true });
587
+ } else {
588
+ try {
589
+ kill(-pid, force ? 'SIGKILL' : 'SIGTERM');
590
+ } catch {
591
+ kill(pid, force ? 'SIGKILL' : 'SIGTERM');
592
+ }
593
+ }
594
+ } catch {
595
+ // процесс уже мог завершиться
596
+ }
597
+ }
598
+
599
+ // --- установка EDT ----------------------------------------------------------
600
+
601
+ /** Установлен ли в этой EDT плагин EDT-MCP (эвристика по именам бандлов). */
602
+ export function hasMcpBundle(home) {
603
+ for (const dir of ['plugins', 'features']) {
604
+ let entries;
605
+ try {
606
+ entries = readdirSync(path.join(home, dir));
607
+ } catch {
608
+ continue;
609
+ }
610
+ if (entries.some((name) => /mcp/i.test(name) || /ditrix/i.test(name))) return true;
611
+ }
612
+ return false;
613
+ }
614
+
615
+ const VERSION_PATTERN = /^1c-edt-(\d{4})\.(\d+)\.(\d+)\+(\d+)-/;
616
+
617
+ /** Версия установки EDT для сортировки (null — не установка EDT). */
618
+ export function edtVersionOf(name) {
619
+ const match = VERSION_PATTERN.exec(String(name ?? ''));
620
+ if (match === null) return null;
621
+ return match.slice(1).map((part) => Number.parseInt(part, 10));
622
+ }
623
+
624
+ const compareVersion = (left, right) => {
625
+ for (let index = 0; index < Math.max(left.length, right.length); index++) {
626
+ const diff = (left[index] ?? 0) - (right[index] ?? 0);
627
+ if (diff !== 0) return diff;
628
+ }
629
+ return 0;
630
+ };
631
+
632
+ /**
633
+ * Установка EDT: явная из настроек/`DSH_1C_EDT_HOME` либо старшая найденная.
634
+ * @returns {{ home?: string, bin?: string, cli?: string, mcpBundle?: boolean, source?: string, error?: string }}
635
+ */
636
+ export function detectEdtHome(settings, { platform = process.platform, env = process.env, roots } = {}) {
637
+ const binary = platform === 'win32' ? '1cedt.exe' : '1cedt';
638
+ const cli = platform === 'win32' ? '1cedtcli.exe' : '1cedtcli';
639
+ const explicit = text(env?.DSH_1C_EDT_HOME) ?? text(settings?.home?.[platform]);
640
+ if (explicit !== undefined) {
641
+ const home = path.normalize(explicit);
642
+ const bin = path.join(home, binary);
643
+ if (!existsSync(bin)) return { error: `в ${home} нет ${binary}`, home };
644
+ const cliPath = path.join(home, cli);
645
+ return {
646
+ home,
647
+ bin,
648
+ cli: existsSync(cliPath) ? cliPath : undefined,
649
+ mcpBundle: hasMcpBundle(home),
650
+ source: text(env?.DSH_1C_EDT_HOME) ? 'env' : 'config',
651
+ };
652
+ }
653
+
654
+ const searchRoots = roots ?? EDT_ROOTS[platform] ?? [];
655
+ const candidates = [];
656
+ for (const root of searchRoots) {
657
+ let entries;
658
+ try {
659
+ entries = readdirSync(root, { withFileTypes: true });
660
+ } catch {
661
+ continue;
662
+ }
663
+ for (const entry of entries) {
664
+ if (!entry.isDirectory()) continue;
665
+ const version = edtVersionOf(entry.name);
666
+ if (version === null) continue;
667
+ const home = path.join(root, entry.name);
668
+ if (!existsSync(path.join(home, binary))) continue;
669
+ candidates.push({ home, version, mcpBundle: hasMcpBundle(home) });
670
+ }
671
+ }
672
+ if (candidates.length === 0) {
673
+ return { error: `1C:EDT не найден; задайте workspace.home или DSH_1C_EDT_HOME (искали в ${searchRoots.join(', ')})` };
674
+ }
675
+ candidates.sort((left, right) => compareVersion(right.version, left.version));
676
+ const chosen = candidates.find((candidate) => candidate.mcpBundle) ?? candidates[0];
677
+ const cliPath = path.join(chosen.home, cli);
678
+ return {
679
+ home: chosen.home,
680
+ bin: path.join(chosen.home, binary),
681
+ cli: existsSync(cliPath) ? cliPath : undefined,
682
+ mcpBundle: chosen.mcpBundle,
683
+ source: 'autodetect',
684
+ };
685
+ }
686
+
687
+ /** Java для EDT: явная настройка, env либо jdk-full-17 рядом с установкой. */
688
+ export function resolveEdtVm(settings, home, { platform = process.platform, env = process.env } = {}) {
689
+ const explicit = text(env?.DSH_1C_EDT_VM) ?? text(settings?.vm);
690
+ if (explicit !== undefined) return explicit;
691
+ if (typeof home !== 'string' || home === '') return undefined;
692
+ const javaBinary = platform === 'win32' ? 'javaw.exe' : 'java';
693
+ try {
694
+ const components = path.dirname(home);
695
+ const jdk = readdirSync(components, { withFileTypes: true })
696
+ .filter((entry) => entry.isDirectory() && /jdk-full-17/.test(entry.name))
697
+ .map((entry) => path.join(components, entry.name, 'bin', javaBinary))
698
+ .filter((candidate) => existsSync(candidate))
699
+ .sort();
700
+ return jdk[0];
701
+ } catch {
702
+ return undefined;
703
+ }
704
+ }
705
+
706
+ /** Аргументы запуска EDT GUI с отдельным workspace. */
707
+ export function guiArgs({ wsDir, xmx, vm }) {
708
+ const args = ['-nosplash', '-data', wsDir, '--launcher.appendVmargs'];
709
+ if (typeof vm === 'string' && vm !== '') args.push('-vm', vm);
710
+ args.push('-vmargs', `-Xmx${xmx}`);
711
+ return args;
712
+ }
713
+
714
+ /** Аргументы `1cedtcli` для импорта проектов. */
715
+ export function importArgs({ wsDir, timeout, xmxImport }, scriptPath) {
716
+ return ['-data', wsDir, '-timeout', String(timeout), '-vmargs', `-Xmx${xmxImport}`, '-file', scriptPath];
717
+ }
718
+
719
+ /** Адрес проверки готовности EDT-MCP (не требует токена). */
720
+ export function healthUrl(port) {
721
+ return `http://127.0.0.1:${port}/health`;
722
+ }
723
+
724
+ /** Отвечает ли MCP-сервер EDT на health-запрос. */
725
+ export async function probeHealth(port, deps = {}) {
726
+ if (!Number.isInteger(port) || port <= 0) return false;
727
+ const implementation = deps.fetchImpl ?? globalThis.fetch;
728
+ if (typeof implementation !== 'function') return false;
729
+ try {
730
+ const response = await implementation(healthUrl(port), { signal: AbortSignal.timeout(deps.timeoutMs ?? 3000) });
731
+ // /health EDT отвечает 200; 3xx/4xx на этом порту — посторонний сервис.
732
+ return response.status >= 200 && response.status < 300;
733
+ } catch {
734
+ return false;
735
+ }
736
+ }
737
+
738
+ // --- состояние --------------------------------------------------------------
739
+
740
+ /**
741
+ * Состояние окружения по файлам. Живость процесса проверяется только когда
742
+ * передан `alive` (по умолчанию — настоящая проверка pid).
743
+ * @returns {{ exists: boolean, label: string, port: number|null, pid: object|null, ready: boolean,
744
+ * failed: boolean, imported: object|null, progress: object|null }}
745
+ */
746
+ export function stateOf(buildDir, deps = {}) {
747
+ const alive = deps.alive ?? ((pid) => pidAlive(pid, ['1cedt']));
748
+ const exists = existsSync(buildDir);
749
+ const port = readPort(buildDir);
750
+ const pid = readPid(buildDir);
751
+ const ready = existsSync(filePath(buildDir, 'ready'));
752
+ const failed = existsSync(filePath(buildDir, 'failed'));
753
+ const imported = readImported(buildDir);
754
+ const progress = readProgress(buildDir);
755
+ const running = pid !== null && alive(pid.pid);
756
+
757
+ let label;
758
+ if (!exists) label = 'absent';
759
+ else if (failed) label = 'failed';
760
+ else if (ready) label = running ? 'ready' : 'stale';
761
+ else if (running) label = 'starting';
762
+ else if (progress?.edt?.phase === 'failed') label = 'failed';
763
+ else if (progress?.edt?.phase !== undefined && !['failed', 'ready'].includes(progress.edt.phase)) label = 'starting';
764
+ else label = 'stopped';
765
+
766
+ return { exists, label, port, pid, ready, failed, imported, progress };
767
+ }
768
+
769
+ /** Человеческая длительность от ISO-момента. */
770
+ export function formatElapsed(sinceIso, nowMs = Date.now()) {
771
+ const started = Date.parse(String(sinceIso ?? ''));
772
+ if (Number.isNaN(started)) return '?';
773
+ const seconds = Math.max(0, Math.round((nowMs - started) / 1000));
774
+ const minutes = Math.floor(seconds / 60);
775
+ return minutes > 0 ? `${minutes}m ${String(seconds % 60).padStart(2, '0')}s` : `${seconds}s`;
776
+ }