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,521 @@
1
+ #!/usr/bin/env node
2
+ // Фоновый подъём и остановка EDT-окружения worktree.
3
+ //
4
+ // Запускается плагином (lib/index.js) и CLI (tools/edt-workspace.mjs):
5
+ // node lib/workspace-run.mjs init --root <worktree>
6
+ // node lib/workspace-run.mjs run --root <worktree> [--import]
7
+ // node lib/workspace-run.mjs status --root <worktree> [--json]
8
+ // node lib/workspace-run.mjs stop --root <worktree>
9
+ // node lib/workspace-run.mjs stop-all --repo <repository>
10
+ //
11
+ // `init` быстрый: занимает порт, пишет prefs и запускает `run` отсоединённым
12
+ // процессом. Долгие шаги (импорт 1cedtcli, старт EDT, ожидание /health) живут
13
+ // в `run`, поэтому DSH не блокируется, а окружение переживает перезапуск DSH.
14
+ // Все зависимости, которые трогают процессы и время, передаются через `deps` —
15
+ // так сценарии проверяются без запуска настоящего EDT.
16
+ import { execFile, execFileSync, spawn } from 'node:child_process';
17
+ import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
18
+ import { promisify } from 'node:util';
19
+ import path from 'node:path';
20
+ import { fileURLToPath, pathToFileURL } from 'node:url';
21
+
22
+ import {
23
+ buildWorkspaceSnapshot,
24
+ clearProgress,
25
+ detectEdtHome,
26
+ discoverProjects,
27
+ filePath,
28
+ formatElapsed,
29
+ guiArgs,
30
+ importArgs,
31
+ importScriptText,
32
+ isPortFree,
33
+ killTree,
34
+ normalizeWorkspaceSettings,
35
+ pickFreePort,
36
+ pidAlive,
37
+ probeHealth,
38
+ readImported,
39
+ readPid,
40
+ readPort,
41
+ readWorkspaceSnapshot,
42
+ registeredProjectsDir,
43
+ resolveEdtVm,
44
+ stateOf,
45
+ tailFile,
46
+ writeImported,
47
+ writePid,
48
+ writePort,
49
+ writePrefs,
50
+ writeProgress,
51
+ writeStamp,
52
+ removeStamp,
53
+ writeWorkspaceSnapshot,
54
+ } from './workspace.mjs';
55
+
56
+ const execFileAsync = promisify(execFile);
57
+ const SELF = fileURLToPath(import.meta.url);
58
+ const HEALTH_POLL_MS = 2000;
59
+ const STOP_GRACE_MS = 10_000;
60
+
61
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
62
+
63
+ /** Дописывает строку в build/logs/init.log; в терминале (TTY) ещё и печатает. */
64
+ export function logTo(buildDir, message, now = new Date()) {
65
+ const line = `[${now.toISOString()}] ${message}`;
66
+ try {
67
+ mkdirSync(path.join(buildDir, 'logs'), { recursive: true });
68
+ writeFileSync(path.join(buildDir, 'logs', 'init.log'), `${line}\n`, { encoding: 'utf8', flag: 'a' });
69
+ } catch {
70
+ // лог — вспомогательный: его недоступность не должна ломать подъём
71
+ }
72
+ // У фонового процесса stdout перенаправлен в тот же файл — печатать нельзя,
73
+ // иначе каждая строка попадёт в лог дважды.
74
+ if (process.stdout?.isTTY === true) console.log(line);
75
+ }
76
+
77
+ /** Разбирает argv CLI. */
78
+ export function parseArgs(argv = []) {
79
+ const action = argv.find((item) => !item.startsWith('-')) ?? 'init';
80
+ const valueOf = (name) => {
81
+ const index = argv.indexOf(name);
82
+ return index === -1 ? undefined : argv[index + 1];
83
+ };
84
+ const importIndex = argv.indexOf('--import');
85
+ return {
86
+ action,
87
+ root: valueOf('--root'),
88
+ repo: valueOf('--repo'),
89
+ json: argv.includes('--json'),
90
+ import: importIndex === -1 ? undefined : argv.slice(importIndex + 1).find((item) => !item.startsWith('-')) ?? true,
91
+ };
92
+ }
93
+
94
+ /** Запускает процесс отсоединённым, весь вывод — в файл. */
95
+ export function spawnDetached(command, args, logPath, cwd, deps = {}) {
96
+ mkdirSync(path.dirname(logPath), { recursive: true });
97
+ const out = openSync(logPath, 'a');
98
+ let child;
99
+ try {
100
+ child = (deps.spawn ?? spawn)(command, args, {
101
+ cwd,
102
+ detached: true,
103
+ stdio: ['ignore', out, out],
104
+ windowsHide: true,
105
+ });
106
+ } finally {
107
+ closeSync(out);
108
+ }
109
+ if (child?.pid) child.unref();
110
+ return child;
111
+ }
112
+
113
+ /** Запускает команду с выводом в лог, дожидаясь завершения. */
114
+ export async function runCapture(command, args, logPath, deps = {}) {
115
+ mkdirSync(path.dirname(logPath), { recursive: true });
116
+ const out = openSync(logPath, 'a');
117
+ try {
118
+ await (deps.runCapture ??
119
+ ((cmd, argv, options) => execFileAsync(cmd, argv, { ...options, encoding: 'utf8' }).then(() => undefined)))(
120
+ command,
121
+ args,
122
+ { stdio: ['ignore', out, out], windowsHide: true },
123
+ );
124
+ } finally {
125
+ closeSync(out);
126
+ }
127
+ }
128
+
129
+ function lockInfo(file) {
130
+ try {
131
+ return JSON.parse(readFileSync(file, 'utf8'));
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ /** Захватывает lock; чужой живой lock — сигнал «уже идёт». */
138
+ export function acquireLock(file, deps = {}) {
139
+ mkdirSync(path.dirname(file), { recursive: true });
140
+ const alive = deps.alive ?? ((pid) => pidAlive(pid));
141
+ const existing = lockInfo(file);
142
+ if (existing?.pid !== undefined) {
143
+ if (alive(existing.pid)) return false;
144
+ try {
145
+ unlinkSync(file);
146
+ } catch {
147
+ // lock мог уже исчезнуть
148
+ }
149
+ }
150
+ try {
151
+ writeFileSync(file, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }), { flag: 'wx' });
152
+ return true;
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
157
+
158
+ export function releaseLock(file) {
159
+ const info = lockInfo(file);
160
+ if (info === null || info.pid === process.pid) {
161
+ try {
162
+ unlinkSync(file);
163
+ } catch {
164
+ // lock уже снят
165
+ }
166
+ }
167
+ }
168
+
169
+ /** Нужно ли импортировать проекты в этот workspace. */
170
+ export function shouldImport(snapshot, { force } = {}, deps = {}) {
171
+ const settings = snapshot.settings;
172
+ const projects = Array.isArray(snapshot.projects) ? snapshot.projects : [];
173
+ if (projects.length === 0) return false;
174
+ if (force === true || snapshot.forceImport === true) return true;
175
+ if (settings.import === false) return false;
176
+
177
+ const imported = deps.readImported ?? readImported;
178
+ const marker = imported(snapshot.buildDir);
179
+ const same =
180
+ marker !== null &&
181
+ Array.isArray(marker.projects) &&
182
+ marker.projects.length === projects.length &&
183
+ marker.projects.every((item, index) => item === projects[index]);
184
+ if (same) return false;
185
+ if (settings.import === true) return true;
186
+
187
+ // "auto": либо маркер уже есть (список мог измениться — это повод для
188
+ // явного restart с import: true, но не для фонового импорта), либо в
189
+ // workspace уже есть проекты (их импортировали руками).
190
+ if (marker !== null) return false;
191
+ const registered = deps.hasRegisteredProjects ?? defaultHasRegisteredProjects;
192
+ return !registered(registeredProjectsDir(snapshot.buildDir));
193
+ }
194
+
195
+ function defaultHasRegisteredProjects(dir) {
196
+ try {
197
+ return readdirSync(dir).length > 0;
198
+ } catch {
199
+ return false;
200
+ }
201
+ }
202
+
203
+ function snapshotOrThrow(root) {
204
+ const buildDir = path.join(root, 'build');
205
+ const snapshot = readWorkspaceSnapshot(buildDir);
206
+ if (snapshot === undefined) {
207
+ throw new Error(`нет ${path.join(buildDir, 'workspace.json')} — окружение ещё не готовили; вызовите init`);
208
+ }
209
+ return snapshot;
210
+ }
211
+
212
+ /** Снимок по умолчанию для CLI, когда workspace.json ещё нет. */
213
+ export function defaultSnapshot(root, env = process.env) {
214
+ const settings = normalizeWorkspaceSettings({}, env);
215
+ const buildDir = path.join(root, 'build');
216
+ return buildWorkspaceSnapshot({
217
+ root,
218
+ buildDir,
219
+ wsDir: path.join(buildDir, 'ws'),
220
+ mainRoot: undefined,
221
+ settings,
222
+ projects: discoverProjects(root, settings.projects),
223
+ });
224
+ }
225
+
226
+ /**
227
+ * `init`: быстро занимает порт, пишет prefs и запускает `run` в фоне.
228
+ * @returns {Promise<number>} код возврата.
229
+ */
230
+ export async function cmdInit(root, args = {}, deps = {}) {
231
+ const snapshot = deps.snapshot ?? readWorkspaceSnapshot(path.join(root, 'build')) ?? defaultSnapshot(root);
232
+ const buildDir = snapshot.buildDir;
233
+ mkdirSync(buildDir, { recursive: true });
234
+ writeWorkspaceSnapshot(buildDir, snapshot);
235
+
236
+ const state = stateOf(buildDir, { alive: deps.alive });
237
+ if (state.label === 'ready' || state.label === 'starting') {
238
+ logTo(buildDir, `init: окружение уже ${state.label === 'ready' ? 'готово' : 'поднимается'}, делать нечего`);
239
+ return 0;
240
+ }
241
+
242
+ const lock = filePath(buildDir, 'lock');
243
+ if (!acquireLock(lock, deps)) {
244
+ logTo(buildDir, 'init: другой процесс уже занимает порт, выходим');
245
+ return 0;
246
+ }
247
+ try {
248
+ let port = readPort(buildDir);
249
+ const free = deps.isPortFree ?? isPortFree;
250
+ if (port === null || !(await free(port))) {
251
+ port = await (deps.pickFreePort ?? pickFreePort)(snapshot.settings.ports, { isFree: free });
252
+ writePort(buildDir, port);
253
+ }
254
+ const prefs = writePrefs(buildDir, port);
255
+ logTo(buildDir, `init: порт ${port}, prefs ${path.relative(root, prefs)}, запускаю run в фоне`);
256
+ const logPath = path.join(buildDir, 'logs', 'init.log');
257
+ (deps.spawnDetached ?? spawnDetached)(process.execPath, [SELF, 'run', '--root', root], logPath, root, deps);
258
+ } finally {
259
+ releaseLock(lock);
260
+ }
261
+ return 0;
262
+ }
263
+
264
+ /**
265
+ * `run`: импорт проектов, prefs, старт EDT и ожидание готовности.
266
+ * @returns {Promise<number>}
267
+ */
268
+ export async function cmdRun(root, args = {}, deps = {}) {
269
+ const snapshot = deps.snapshot ?? readWorkspaceSnapshot(path.join(root, 'build'));
270
+ if (snapshot === undefined) {
271
+ console.error('run: нет workspace.json — сначала init');
272
+ return 1;
273
+ }
274
+ const buildDir = snapshot.buildDir;
275
+ const settings = snapshot.settings;
276
+ const log = (message) => logTo(buildDir, message, deps.now?.());
277
+ mkdirSync(path.join(buildDir, 'logs'), { recursive: true });
278
+
279
+ const lock = filePath(buildDir, 'runLock');
280
+ if (!acquireLock(lock, deps)) {
281
+ log('run: другой запуск уже идёт, выходим');
282
+ return 0;
283
+ }
284
+
285
+ try {
286
+ const state = stateOf(buildDir, { alive: deps.alive });
287
+ if (state.label === 'ready') {
288
+ log('run: окружение уже готово');
289
+ return 0;
290
+ }
291
+
292
+ const detected = (deps.detectEdtHome ?? detectEdtHome)(settings, deps);
293
+ if (detected?.error !== undefined || detected?.home === undefined) {
294
+ writeProgress(buildDir, 'edt', 'failed', detected?.error ?? 'установка EDT не найдена');
295
+ writeStamp(buildDir, 'failed', deps.now?.());
296
+ log(`edt: FAILED: ${detected?.error ?? 'установка EDT не найдена'}`);
297
+ return 1;
298
+ }
299
+ if (detected.mcpBundle !== true) {
300
+ log('edt: предупреждение — в установке не найден бандл EDT-MCP (проверьте plugins/features)');
301
+ }
302
+
303
+ const port = readPort(buildDir);
304
+ if (port === null) {
305
+ writeProgress(buildDir, 'edt', 'failed', 'нет порта: init не отработал');
306
+ writeStamp(buildDir, 'failed', deps.now?.());
307
+ log('edt: FAILED: нет файла порта');
308
+ return 1;
309
+ }
310
+
311
+ const importNeeded = (deps.shouldImport ?? shouldImport)(snapshot, { force: args.import === true }, deps);
312
+ if (importNeeded && detected.cli === undefined) {
313
+ writeProgress(buildDir, 'edt', 'import-failed', 'в установке нет 1cedtcli — импорт невозможен; задайте workspace.import=false или полную установку EDT');
314
+ writeStamp(buildDir, 'failed', deps.now?.());
315
+ log('edt: FAILED: в установке нет 1cedtcli, импорт проектов невозможен');
316
+ return 1;
317
+ }
318
+ if (importNeeded) {
319
+ const scriptPath = writeImportScript(buildDir, snapshot.projects);
320
+ writeProgress(buildDir, 'edt', 'importing', tailFile(path.join(buildDir, 'logs', 'edt-import.log')));
321
+ log(`edt: импорт ${snapshot.projects.length} проект(ов) в ${path.relative(root, snapshot.wsDir)} (xmx ${settings.xmxImport}, timeout ${settings.importTimeout}s)`);
322
+ const ticker = setInterval(() => {
323
+ const detail = tailFile(path.join(buildDir, 'logs', 'edt-import.log'));
324
+ if (detail) writeProgress(buildDir, 'edt', 'importing', detail);
325
+ }, 10_000);
326
+ ticker.unref?.();
327
+ try {
328
+ await (deps.runCapture ?? runCapture)(
329
+ detected.cli,
330
+ importArgs({ ...snapshot, wsDir: snapshot.wsDir, timeout: settings.importTimeout, xmxImport: settings.xmxImport }, scriptPath),
331
+ path.join(buildDir, 'logs', 'edt-import.log'),
332
+ deps,
333
+ );
334
+ } catch (error) {
335
+ clearInterval(ticker);
336
+ const tail = tailFile(path.join(buildDir, 'logs', 'edt-import.log'));
337
+ writeProgress(buildDir, 'edt', 'import-failed', `${tail ?? error?.message ?? error}; см. build/logs/edt-import.log`);
338
+ writeStamp(buildDir, 'failed', deps.now?.());
339
+ log(`edt: FAILED: импорт не удался — ${error?.message ?? error}; лог build/logs/edt-import.log`);
340
+ return 1;
341
+ }
342
+ clearInterval(ticker);
343
+ writeImported(buildDir, snapshot.projects, deps.now?.());
344
+ log('edt: импорт завершён');
345
+ if (snapshot.forceImport === true) {
346
+ const { forceImport, ...rest } = snapshot;
347
+ writeWorkspaceSnapshot(buildDir, rest);
348
+ }
349
+ } else {
350
+ log('edt: импорт не требуется');
351
+ }
352
+
353
+ writePrefs(buildDir, port);
354
+ const runningPid = readPid(buildDir)?.pid;
355
+ if (runningPid !== undefined && (deps.alive ?? ((pid) => pidAlive(pid, ['1cedt'])))(runningPid)) {
356
+ log(`edt: уже запущен (pid ${runningPid})`);
357
+ } else {
358
+ const vm = (deps.resolveEdtVm ?? resolveEdtVm)(settings, detected.home, deps);
359
+ writeProgress(buildDir, 'edt', 'gui');
360
+ const gui = (deps.spawnDetached ?? spawnDetached)(
361
+ detected.bin,
362
+ guiArgs({ wsDir: snapshot.wsDir, xmx: settings.xmxIde, vm }),
363
+ path.join(buildDir, 'logs', 'edt.log'),
364
+ root,
365
+ deps,
366
+ );
367
+ if (!gui?.pid) {
368
+ writeProgress(buildDir, 'edt', 'failed', 'не удалось запустить 1cedt');
369
+ writeStamp(buildDir, 'failed', deps.now?.());
370
+ log('edt: FAILED: не удалось запустить 1cedt');
371
+ return 1;
372
+ }
373
+ writePid(buildDir, gui.pid, deps.now?.());
374
+ log(`edt: запущен pid ${gui.pid} (workspace ${snapshot.wsDir})`);
375
+ }
376
+
377
+ removeStamp(buildDir, 'ready');
378
+ writeProgress(buildDir, 'edt', 'health');
379
+ log(`edt: жду /health на порту ${port} (до ${Math.round(settings.startTimeout / 60)} мин)`);
380
+ const ready = await (deps.waitHealth ?? waitHealth)(port, settings.startTimeout * 1000, deps);
381
+ if (ready) {
382
+ writeProgress(buildDir, 'edt', 'ready');
383
+ writeStamp(buildDir, 'ready', deps.now?.());
384
+ removeStamp(buildDir, 'failed');
385
+ log('edt: READY');
386
+ return 0;
387
+ }
388
+ writeProgress(buildDir, 'edt', 'failed', 'health timeout');
389
+ writeStamp(buildDir, 'failed', deps.now?.());
390
+ log('edt: FAILED: health timeout');
391
+ return 1;
392
+ } finally {
393
+ releaseLock(lock);
394
+ }
395
+ }
396
+
397
+ function writeImportScript(buildDir, projects) {
398
+ const file = filePath(buildDir, 'script');
399
+ writeFileSync(file, importScriptText(projects), 'utf8');
400
+ return file;
401
+ }
402
+
403
+ /** Ждёт /health до истечения таймаута. */
404
+ export async function waitHealth(port, timeoutMs, deps = {}) {
405
+ const probe = deps.probeHealth ?? probeHealth;
406
+ const delay = deps.sleep ?? sleep;
407
+ const pollMs = deps.pollMs ?? HEALTH_POLL_MS;
408
+ const deadline = Date.now() + timeoutMs;
409
+ while (Date.now() < deadline) {
410
+ if (await probe(port, deps)) return true;
411
+ await delay(pollMs);
412
+ }
413
+ return false;
414
+ }
415
+
416
+ /** Останавливает EDT этого worktree. */
417
+ export async function cmdStop(root, deps = {}) {
418
+ const buildDir = path.join(root, 'build');
419
+ const pidInfo = readPid(buildDir);
420
+ if (pidInfo === null) {
421
+ clearProgress(buildDir);
422
+ return { stopped: false, reason: 'нет pid — нечего останавливать' };
423
+ }
424
+ const alive = deps.alive ?? ((pid) => pidAlive(pid, ['1cedt']));
425
+ const pid = pidInfo.pid;
426
+ if (!alive(pid)) {
427
+ clearState(buildDir);
428
+ return { stopped: false, reason: `pid ${pid} уже не жив` };
429
+ }
430
+
431
+ (deps.killTree ?? killTree)(pid, { force: false, ...deps });
432
+ const delay = deps.sleep ?? sleep;
433
+ const deadline = Date.now() + STOP_GRACE_MS;
434
+ while (alive(pid) && Date.now() < deadline) await delay(300);
435
+ if (alive(pid)) (deps.killTree ?? killTree)(pid, { force: true, ...deps });
436
+ clearState(buildDir);
437
+ return { stopped: true, pid };
438
+ }
439
+
440
+ function clearState(buildDir) {
441
+ for (const key of ['ready', 'failed', 'pid']) removeStamp(buildDir, key);
442
+ clearProgress(buildDir);
443
+ }
444
+
445
+ /** Останавливает окружения всех worktree репозитория. */
446
+ export async function cmdStopAll({ repo, root }, deps = {}) {
447
+ const run = deps.runGit ?? ((dir, args) => execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8' }));
448
+ const list = run(root ?? repo, ['worktree', 'list', '--porcelain']);
449
+ const roots = [];
450
+ for (const line of String(list).split(/\r?\n/)) {
451
+ if (line.startsWith('worktree ')) roots.push(line.slice('worktree '.length).trim());
452
+ }
453
+ const results = [];
454
+ for (const worktree of roots) {
455
+ const pid = readPid(path.join(worktree, 'build'));
456
+ if (pid === null) continue;
457
+ results.push({ worktree, ...(await cmdStop(worktree, deps)) });
458
+ }
459
+ return results;
460
+ }
461
+
462
+ /** Текстовый отчёт о состоянии (CLI и тесты). */
463
+ export function statusReport(root, deps = {}) {
464
+ const buildDir = path.join(root, 'build');
465
+ const snapshot = readWorkspaceSnapshot(buildDir);
466
+ const state = stateOf(buildDir, { alive: deps.alive });
467
+ const progress = state.progress?.edt;
468
+ const lines = [
469
+ `worktree: ${root}`,
470
+ `состояние: ${state.label}`,
471
+ `workspace: ${snapshot?.wsDir ?? path.join(buildDir, 'ws')}`,
472
+ `порт: ${state.port ?? '-'} pid: ${state.pid?.pid ?? '-'}`,
473
+ ];
474
+ if (progress?.phase) {
475
+ lines.push(`фаза: ${progress.phase}${progress.since ? ` (${formatElapsed(progress.since)})` : ''}${progress.detail ? ` — ${progress.detail}` : ''}`);
476
+ }
477
+ if (state.imported?.projects) lines.push(`импортировано проектов: ${state.imported.projects.length}`);
478
+ lines.push(`логи: ${path.join(buildDir, 'logs')}`);
479
+ return { root, buildDir, snapshot, state, lines, data: { ...state, workspace: snapshot?.wsDir, root } };
480
+ }
481
+
482
+ /** Точка входа CLI. */
483
+ export async function main(argv = process.argv.slice(2), deps = {}) {
484
+ const args = parseArgs(argv);
485
+ const root = args.root ?? process.cwd();
486
+ switch (args.action) {
487
+ case 'init':
488
+ return cmdInit(root, args, deps);
489
+ case 'run':
490
+ return cmdRun(root, args, deps);
491
+ case 'status': {
492
+ const report = statusReport(root, deps);
493
+ if (args.json) console.log(JSON.stringify(report.data, null, 2));
494
+ else for (const line of report.lines) console.log(line);
495
+ return 0;
496
+ }
497
+ case 'stop': {
498
+ const result = await cmdStop(root, deps);
499
+ console.log(result.stopped ? `остановлен pid ${result.pid}` : `не остановлен: ${result.reason}`);
500
+ return 0;
501
+ }
502
+ case 'stop-all': {
503
+ const results = await cmdStopAll({ repo: args.repo ?? root, root: args.root }, deps);
504
+ console.log(JSON.stringify(results, null, 2));
505
+ return 0;
506
+ }
507
+ default:
508
+ console.log('usage: node lib/workspace-run.mjs [init|run|status|stop|stop-all] [--root <dir>] [--repo <dir>] [--import] [--json]');
509
+ return 2;
510
+ }
511
+ }
512
+
513
+ const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
514
+ if (isMain) {
515
+ main()
516
+ .then((code) => process.exit(code ?? 0))
517
+ .catch((error) => {
518
+ console.error(`workspace-run: ${error?.message ?? error}`);
519
+ process.exit(1);
520
+ });
521
+ }