dsh-edt-projects 0.11.0 → 0.12.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/README.md +17 -3
- package/cordis.patch.yml +10 -0
- package/lib/bsl.mjs +3 -1
- package/lib/config.mjs +41 -8
- package/lib/enable.mjs +28 -0
- package/lib/index.js +615 -51
- package/lib/repo.mjs +185 -0
- package/lib/schema.mjs +84 -1
- package/lib/workspace-run.mjs +690 -0
- package/lib/workspace.mjs +1089 -0
- package/locale/en.json +1 -1
- package/locale/ru.json +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,690 @@
|
|
|
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, statSync, 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
|
+
appendImportDigest,
|
|
24
|
+
buildWorkspaceSnapshot,
|
|
25
|
+
clearProgress,
|
|
26
|
+
detectEdtHome,
|
|
27
|
+
discoverProjects,
|
|
28
|
+
filePath,
|
|
29
|
+
formatElapsed,
|
|
30
|
+
guiArgs,
|
|
31
|
+
importArgs,
|
|
32
|
+
importDigest,
|
|
33
|
+
importMemoryWarning,
|
|
34
|
+
importScriptText,
|
|
35
|
+
isPortFree,
|
|
36
|
+
killTree,
|
|
37
|
+
normalizeWorkspaceSettings,
|
|
38
|
+
pickFreePort,
|
|
39
|
+
pidAlive,
|
|
40
|
+
probeHealth,
|
|
41
|
+
readImported,
|
|
42
|
+
readPid,
|
|
43
|
+
readPort,
|
|
44
|
+
readWorkspaceSnapshot,
|
|
45
|
+
registeredProjectsDir,
|
|
46
|
+
requiredJava,
|
|
47
|
+
resolveEdtVm,
|
|
48
|
+
stateOf,
|
|
49
|
+
tailFile,
|
|
50
|
+
writeImported,
|
|
51
|
+
writePid,
|
|
52
|
+
writePort,
|
|
53
|
+
writePrefs,
|
|
54
|
+
writeProgress,
|
|
55
|
+
writeStamp,
|
|
56
|
+
removeStamp,
|
|
57
|
+
writeWorkspaceSnapshot,
|
|
58
|
+
} from './workspace.mjs';
|
|
59
|
+
|
|
60
|
+
const execFileAsync = promisify(execFile);
|
|
61
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
62
|
+
const HEALTH_POLL_MS = 2000;
|
|
63
|
+
const STOP_GRACE_MS = 10_000;
|
|
64
|
+
|
|
65
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
66
|
+
|
|
67
|
+
/** Дописывает строку в build/logs/init.log; в терминале (TTY) ещё и печатает. */
|
|
68
|
+
export function logTo(buildDir, message, now = new Date()) {
|
|
69
|
+
const line = `[${now.toISOString()}] ${message}`;
|
|
70
|
+
try {
|
|
71
|
+
mkdirSync(path.join(buildDir, 'logs'), { recursive: true });
|
|
72
|
+
writeFileSync(path.join(buildDir, 'logs', 'init.log'), `${line}\n`, { encoding: 'utf8', flag: 'a' });
|
|
73
|
+
} catch {
|
|
74
|
+
// лог — вспомогательный: его недоступность не должна ломать подъём
|
|
75
|
+
}
|
|
76
|
+
// У фонового процесса stdout перенаправлен в тот же файл — печатать нельзя,
|
|
77
|
+
// иначе каждая строка попадёт в лог дважды.
|
|
78
|
+
if (process.stdout?.isTTY === true) console.log(line);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Разбирает argv CLI. */
|
|
82
|
+
export function parseArgs(argv = []) {
|
|
83
|
+
const action = argv.find((item) => !item.startsWith('-')) ?? 'init';
|
|
84
|
+
const valueOf = (name) => {
|
|
85
|
+
const index = argv.indexOf(name);
|
|
86
|
+
return index === -1 ? undefined : argv[index + 1];
|
|
87
|
+
};
|
|
88
|
+
const importIndex = argv.indexOf('--import');
|
|
89
|
+
return {
|
|
90
|
+
action,
|
|
91
|
+
root: valueOf('--root'),
|
|
92
|
+
repo: valueOf('--repo'),
|
|
93
|
+
json: argv.includes('--json'),
|
|
94
|
+
import: importIndex === -1 ? undefined : argv.slice(importIndex + 1).find((item) => !item.startsWith('-')) ?? true,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Запускает процесс отсоединённым, весь вывод — в файл. */
|
|
99
|
+
export function spawnDetached(command, args, logPath, cwd, deps = {}) {
|
|
100
|
+
mkdirSync(path.dirname(logPath), { recursive: true });
|
|
101
|
+
const out = openSync(logPath, 'a');
|
|
102
|
+
let child;
|
|
103
|
+
try {
|
|
104
|
+
child = (deps.spawn ?? spawn)(command, args, {
|
|
105
|
+
cwd,
|
|
106
|
+
detached: true,
|
|
107
|
+
stdio: ['ignore', out, out],
|
|
108
|
+
windowsHide: true,
|
|
109
|
+
});
|
|
110
|
+
} finally {
|
|
111
|
+
closeSync(out);
|
|
112
|
+
}
|
|
113
|
+
if (child?.pid) child.unref();
|
|
114
|
+
return child;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Запускает команду с выводом в лог, дожидаясь завершения. */
|
|
118
|
+
export async function runCapture(command, args, logPath, deps = {}) {
|
|
119
|
+
mkdirSync(path.dirname(logPath), { recursive: true });
|
|
120
|
+
const out = openSync(logPath, 'a');
|
|
121
|
+
try {
|
|
122
|
+
await (deps.runCapture ??
|
|
123
|
+
((cmd, argv, options) => execFileAsync(cmd, argv, { ...options, encoding: 'utf8' }).then(() => undefined)))(
|
|
124
|
+
command,
|
|
125
|
+
args,
|
|
126
|
+
{ stdio: ['ignore', out, out], windowsHide: true },
|
|
127
|
+
);
|
|
128
|
+
} finally {
|
|
129
|
+
closeSync(out);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function lockInfo(file) {
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Сколько живёт lock без обновления, если владелец не отвечает. */
|
|
142
|
+
const LOCK_MAX_AGE_MS = 6 * 60 * 60_000;
|
|
143
|
+
|
|
144
|
+
/** Состояние lock для диагностики: живой ли pid, возраст, просрочен ли. */
|
|
145
|
+
export function lockState(file, deps = {}) {
|
|
146
|
+
const info = lockInfo(file);
|
|
147
|
+
if (info === null) return { exists: false };
|
|
148
|
+
const pid = Number.parseInt(info.pid, 10);
|
|
149
|
+
const at = Date.parse(info.at ?? '');
|
|
150
|
+
const ageMs = Number.isNaN(at) ? undefined : Math.max(0, Date.now() - at);
|
|
151
|
+
const maxAgeMs = deps.maxAgeMs ?? LOCK_MAX_AGE_MS;
|
|
152
|
+
const alive = deps.alive ?? ((value) => pidAlive(value));
|
|
153
|
+
const live = Number.isInteger(pid) && pid > 0 && alive(pid);
|
|
154
|
+
return {
|
|
155
|
+
exists: true,
|
|
156
|
+
pid: Number.isInteger(pid) ? pid : undefined,
|
|
157
|
+
at: Number.isNaN(at) ? undefined : new Date(at).toISOString(),
|
|
158
|
+
ageMs,
|
|
159
|
+
stale: !live || (ageMs !== undefined && ageMs > maxAgeMs),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Захватывает lock. Чужой lock считается stale, если pid не жив, образ не
|
|
165
|
+
* совпадает (`image`) или возраст больше `maxAgeMs`; stale перехватывается.
|
|
166
|
+
*/
|
|
167
|
+
export function acquireLock(file, deps = {}) {
|
|
168
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
169
|
+
const alive = deps.alive ?? ((pid) => pidAlive(pid));
|
|
170
|
+
const image = deps.image;
|
|
171
|
+
const maxAgeMs = deps.maxAgeMs ?? LOCK_MAX_AGE_MS;
|
|
172
|
+
const now = deps.now?.() ?? new Date();
|
|
173
|
+
|
|
174
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
175
|
+
const existing = lockInfo(file);
|
|
176
|
+
if (existing !== null) {
|
|
177
|
+
const pid = Number.parseInt(existing.pid, 10);
|
|
178
|
+
const at = Date.parse(existing.at ?? '');
|
|
179
|
+
const tooOld = !Number.isNaN(at) && now.getTime() - at > maxAgeMs;
|
|
180
|
+
const live = Number.isInteger(pid) && pid > 0 && alive(pid);
|
|
181
|
+
const sameImage = live && (image === undefined || pidAlive(pid, [image]));
|
|
182
|
+
if (live && sameImage && !tooOld) return false;
|
|
183
|
+
try {
|
|
184
|
+
unlinkSync(file);
|
|
185
|
+
} catch {
|
|
186
|
+
// lock мог исчезнуть между чтением и удалением
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
writeFileSync(
|
|
191
|
+
file,
|
|
192
|
+
JSON.stringify({
|
|
193
|
+
pid: process.pid,
|
|
194
|
+
at: now.toISOString(),
|
|
195
|
+
image: path.basename(process.execPath),
|
|
196
|
+
}),
|
|
197
|
+
{ flag: 'wx' },
|
|
198
|
+
);
|
|
199
|
+
return true;
|
|
200
|
+
} catch {
|
|
201
|
+
// кто-то успел создать lock — повторим проверку
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function releaseLock(file) {
|
|
208
|
+
const info = lockInfo(file);
|
|
209
|
+
if (info === null || info.pid === process.pid) {
|
|
210
|
+
try {
|
|
211
|
+
unlinkSync(file);
|
|
212
|
+
} catch {
|
|
213
|
+
// lock уже снят
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Нужно ли импортировать проекты в этот workspace. */
|
|
219
|
+
export function shouldImport(snapshot, { force } = {}, deps = {}) {
|
|
220
|
+
const settings = snapshot.settings;
|
|
221
|
+
const projects = Array.isArray(snapshot.projects) ? snapshot.projects : [];
|
|
222
|
+
if (projects.length === 0) return false;
|
|
223
|
+
if (force === true || snapshot.forceImport === true) return true;
|
|
224
|
+
if (settings.import === false) return false;
|
|
225
|
+
|
|
226
|
+
const imported = deps.readImported ?? readImported;
|
|
227
|
+
const marker = imported(snapshot.buildDir);
|
|
228
|
+
const same =
|
|
229
|
+
marker !== null &&
|
|
230
|
+
Array.isArray(marker.projects) &&
|
|
231
|
+
marker.projects.length === projects.length &&
|
|
232
|
+
marker.projects.every((item, index) => item === projects[index]);
|
|
233
|
+
if (same) return false;
|
|
234
|
+
if (settings.import === true) return true;
|
|
235
|
+
|
|
236
|
+
// "auto": либо маркер уже есть (список мог измениться — это повод для
|
|
237
|
+
// явного restart с import: true, но не для фонового импорта), либо в
|
|
238
|
+
// workspace уже есть проекты (их импортировали руками).
|
|
239
|
+
if (marker !== null) return false;
|
|
240
|
+
const registered = deps.hasRegisteredProjects ?? defaultHasRegisteredProjects;
|
|
241
|
+
return !registered(registeredProjectsDir(snapshot.buildDir));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function defaultHasRegisteredProjects(dir) {
|
|
245
|
+
try {
|
|
246
|
+
return readdirSync(dir).length > 0;
|
|
247
|
+
} catch {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function snapshotOrThrow(root) {
|
|
253
|
+
const buildDir = path.join(root, 'build');
|
|
254
|
+
const snapshot = readWorkspaceSnapshot(buildDir);
|
|
255
|
+
if (snapshot === undefined) {
|
|
256
|
+
throw new Error(`нет ${path.join(buildDir, 'workspace.json')} — окружение ещё не готовили; вызовите init`);
|
|
257
|
+
}
|
|
258
|
+
return snapshot;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Снимок по умолчанию для CLI, когда workspace.json ещё нет. */
|
|
262
|
+
export function defaultSnapshot(root, env = process.env) {
|
|
263
|
+
const settings = normalizeWorkspaceSettings({}, env);
|
|
264
|
+
const buildDir = path.join(root, 'build');
|
|
265
|
+
return buildWorkspaceSnapshot({
|
|
266
|
+
root,
|
|
267
|
+
buildDir,
|
|
268
|
+
wsDir: path.join(buildDir, 'ws'),
|
|
269
|
+
mainRoot: undefined,
|
|
270
|
+
settings,
|
|
271
|
+
projects: discoverProjects(root, settings.projects),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* `init`: быстро занимает порт, пишет prefs и запускает `run` в фоне.
|
|
277
|
+
* @returns {Promise<number>} код возврата.
|
|
278
|
+
*/
|
|
279
|
+
export async function cmdInit(root, args = {}, deps = {}) {
|
|
280
|
+
const snapshot = deps.snapshot ?? readWorkspaceSnapshot(path.join(root, 'build')) ?? defaultSnapshot(root);
|
|
281
|
+
const buildDir = snapshot.buildDir;
|
|
282
|
+
mkdirSync(buildDir, { recursive: true });
|
|
283
|
+
writeWorkspaceSnapshot(buildDir, snapshot);
|
|
284
|
+
|
|
285
|
+
const lock = filePath(buildDir, 'lock');
|
|
286
|
+
if (!acquireLock(lock, deps)) {
|
|
287
|
+
logTo(buildDir, 'init: другой процесс уже занимает порт, выходим');
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
// Осиротевший run-lock не должен блокировать повторный init.
|
|
292
|
+
const runLock = filePath(buildDir, 'runLock');
|
|
293
|
+
const maxAgeMs = (Math.max(snapshot.settings.startTimeout, snapshot.settings.importTimeout) + 600) * 1000;
|
|
294
|
+
const running = lockState(runLock, { ...deps, maxAgeMs });
|
|
295
|
+
if (running.exists && running.stale !== true) {
|
|
296
|
+
logTo(buildDir, `init: run уже идёт (pid ${running.pid ?? '?'}, возраст ${running.ageMs === undefined ? '?' : formatElapsed(new Date(Date.now() - running.ageMs).toISOString())})`);
|
|
297
|
+
return 4;
|
|
298
|
+
}
|
|
299
|
+
if (running.exists) {
|
|
300
|
+
try {
|
|
301
|
+
unlinkSync(runLock);
|
|
302
|
+
} catch {
|
|
303
|
+
// lock уже исчез
|
|
304
|
+
}
|
|
305
|
+
logTo(buildDir, 'init: снят осиротевший run-lock');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const state = stateOf(buildDir, { alive: deps.alive });
|
|
309
|
+
if (state.label === 'ready') {
|
|
310
|
+
logTo(buildDir, 'init: окружение уже готово, делать нечего');
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
const alive = deps.alive ?? ((pid) => pidAlive(pid, ['1cedt']));
|
|
314
|
+
if (state.label === 'starting' && state.pid !== null && alive(state.pid.pid)) {
|
|
315
|
+
logTo(buildDir, 'init: окружение уже поднимается, делать нечего');
|
|
316
|
+
return 0;
|
|
317
|
+
}
|
|
318
|
+
if (state.label === 'starting') {
|
|
319
|
+
// Осталась только запись прогресса от упавшего run: начинаем заново.
|
|
320
|
+
clearProgress(buildDir);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let port = readPort(buildDir);
|
|
324
|
+
const free = deps.isPortFree ?? isPortFree;
|
|
325
|
+
if (port === null || !(await free(port))) {
|
|
326
|
+
port = await (deps.pickFreePort ?? pickFreePort)(snapshot.settings.ports, { isFree: free });
|
|
327
|
+
writePort(buildDir, port);
|
|
328
|
+
}
|
|
329
|
+
const prefs = writePrefs(buildDir, port, {
|
|
330
|
+
plainText: snapshot.settings.plainText,
|
|
331
|
+
formScreenshots: snapshot.settings.formScreenshots,
|
|
332
|
+
});
|
|
333
|
+
logTo(buildDir, `init: порт ${port}, prefs ${path.relative(root, prefs)}, запускаю run в фоне`);
|
|
334
|
+
const logPath = path.join(buildDir, 'logs', 'init.log');
|
|
335
|
+
(deps.spawnDetached ?? spawnDetached)(process.execPath, [SELF, 'run', '--root', root], logPath, root, deps);
|
|
336
|
+
} finally {
|
|
337
|
+
releaseLock(lock);
|
|
338
|
+
}
|
|
339
|
+
return 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* `run`: импорт проектов, prefs, старт EDT и ожидание готовности.
|
|
344
|
+
* @returns {Promise<number>}
|
|
345
|
+
*/
|
|
346
|
+
export async function cmdRun(root, args = {}, deps = {}) {
|
|
347
|
+
const snapshot = deps.snapshot ?? readWorkspaceSnapshot(path.join(root, 'build'));
|
|
348
|
+
if (snapshot === undefined) {
|
|
349
|
+
console.error('run: нет workspace.json — сначала init');
|
|
350
|
+
return 1;
|
|
351
|
+
}
|
|
352
|
+
const buildDir = snapshot.buildDir;
|
|
353
|
+
const settings = snapshot.settings;
|
|
354
|
+
const log = (message) => logTo(buildDir, message, deps.now?.());
|
|
355
|
+
mkdirSync(path.join(buildDir, 'logs'), { recursive: true });
|
|
356
|
+
|
|
357
|
+
const lock = filePath(buildDir, 'runLock');
|
|
358
|
+
const maxAgeMs = (Math.max(settings.startTimeout, settings.importTimeout) + 600) * 1000;
|
|
359
|
+
if (!acquireLock(lock, { ...deps, maxAgeMs, image: path.basename(process.execPath) })) {
|
|
360
|
+
const holder = lockState(lock, { ...deps, maxAgeMs });
|
|
361
|
+
log(`run: другой запуск уже идёт (pid ${holder.pid ?? '?'}, возраст ${holder.ageMs === undefined ? '?' : `${Math.round(holder.ageMs / 1000)}s`}), выходим`);
|
|
362
|
+
return 2;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
const state = stateOf(buildDir, { alive: deps.alive });
|
|
367
|
+
if (state.label === 'ready') {
|
|
368
|
+
log('run: окружение уже готово');
|
|
369
|
+
return 0;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const detected = (deps.detectEdtHome ?? detectEdtHome)(settings, deps);
|
|
373
|
+
if (detected?.error !== undefined || detected?.home === undefined) {
|
|
374
|
+
writeProgress(buildDir, 'edt', 'failed', detected?.error ?? 'установка EDT не найдена');
|
|
375
|
+
writeStamp(buildDir, 'failed', deps.now?.());
|
|
376
|
+
log(`edt: FAILED: ${detected?.error ?? 'установка EDT не найдена'}`);
|
|
377
|
+
return 1;
|
|
378
|
+
}
|
|
379
|
+
if (detected.mcpBundle !== true) {
|
|
380
|
+
// Это только файловая эвристика: бандл может стоять в пользовательской
|
|
381
|
+
// configuration area, а готовность подтверждает /health.
|
|
382
|
+
log('edt: EDT-MCP не подтверждён по файлам; готовность проверит /health');
|
|
383
|
+
} else if (Array.isArray(detected.mcpEvidence) && detected.mcpEvidence.length > 0) {
|
|
384
|
+
log(`edt: EDT-MCP найден: ${detected.mcpEvidence[0]}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const port = readPort(buildDir);
|
|
388
|
+
if (port === null) {
|
|
389
|
+
writeProgress(buildDir, 'edt', 'failed', 'нет порта: init не отработал');
|
|
390
|
+
writeStamp(buildDir, 'failed', deps.now?.());
|
|
391
|
+
log('edt: FAILED: нет файла порта');
|
|
392
|
+
return 1;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const importNeeded = (deps.shouldImport ?? shouldImport)(snapshot, { force: args.import === true }, deps);
|
|
396
|
+
if (importNeeded && detected.cli === undefined) {
|
|
397
|
+
writeProgress(buildDir, 'edt', 'import-failed', 'в установке нет 1cedtcli — импорт невозможен; задайте workspace.import=false или полную установку EDT');
|
|
398
|
+
writeStamp(buildDir, 'failed', deps.now?.());
|
|
399
|
+
log('edt: FAILED: в установке нет 1cedtcli, импорт проектов невозможен');
|
|
400
|
+
return 1;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let importWarning;
|
|
404
|
+
let lastImportDetail;
|
|
405
|
+
if (importNeeded) {
|
|
406
|
+
const metadataLog = path.join(snapshot.wsDir, '.metadata', '.log');
|
|
407
|
+
let offset = 0;
|
|
408
|
+
try {
|
|
409
|
+
offset = statSync(metadataLog).size;
|
|
410
|
+
} catch {
|
|
411
|
+
offset = 0;
|
|
412
|
+
}
|
|
413
|
+
const total = snapshot.projects.length;
|
|
414
|
+
const scriptPath = writeImportScript(buildDir, snapshot.projects);
|
|
415
|
+
const seenProjects = new Set();
|
|
416
|
+
let lastProject;
|
|
417
|
+
const tick = () => {
|
|
418
|
+
const digest = (deps.importDigest ?? importDigest)(buildDir, offset, total, deps);
|
|
419
|
+
offset = digest.offset;
|
|
420
|
+
(deps.appendImportDigest ?? appendImportDigest)(buildDir, digest.lines);
|
|
421
|
+
const memory = importMemoryWarning(digest.text);
|
|
422
|
+
if (memory !== undefined) importWarning = memory;
|
|
423
|
+
for (const name of digest.started ?? []) seenProjects.add(name);
|
|
424
|
+
if (digest.current !== undefined) lastProject = digest.current;
|
|
425
|
+
const count = total > 0 ? Math.min(seenProjects.size, total) : seenProjects.size;
|
|
426
|
+
lastImportDetail = `${count}/${total}${lastProject === undefined ? '' : ` — ${lastProject}`}`;
|
|
427
|
+
writeProgress(buildDir, 'edt', 'importing', lastImportDetail, deps.now?.(), memory === undefined ? undefined : { warning: memory });
|
|
428
|
+
};
|
|
429
|
+
lastImportDetail = `0/${total}`;
|
|
430
|
+
writeProgress(buildDir, 'edt', 'importing', lastImportDetail, deps.now?.());
|
|
431
|
+
log(`edt: импорт ${total} проект(ов) в ${path.relative(root, snapshot.wsDir)} (xmx ${settings.xmxImport}, timeout ${settings.importTimeout}s)`);
|
|
432
|
+
const ticker = setInterval(tick, deps.importPollMs ?? 5000);
|
|
433
|
+
ticker.unref?.();
|
|
434
|
+
try {
|
|
435
|
+
await (deps.runCapture ?? runCapture)(
|
|
436
|
+
detected.cli,
|
|
437
|
+
importArgs({ ...snapshot, wsDir: snapshot.wsDir, timeout: settings.importTimeout, xmxImport: settings.xmxImport }, scriptPath),
|
|
438
|
+
path.join(buildDir, 'logs', 'edt-import.log'),
|
|
439
|
+
deps,
|
|
440
|
+
);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
clearInterval(ticker);
|
|
443
|
+
tick();
|
|
444
|
+
const detail = `импорт не удался (${lastImportDetail}): ${error?.message ?? error}; см. build/ws/.metadata/.log`;
|
|
445
|
+
writeProgress(buildDir, 'edt', 'import-failed', detail, deps.now?.(), importWarning === undefined ? undefined : { warning: importWarning });
|
|
446
|
+
writeStamp(buildDir, 'failed', deps.now?.());
|
|
447
|
+
log(`edt: FAILED: ${detail}`);
|
|
448
|
+
return 1;
|
|
449
|
+
}
|
|
450
|
+
clearInterval(ticker);
|
|
451
|
+
tick();
|
|
452
|
+
writeImported(buildDir, snapshot.projects, deps.now?.());
|
|
453
|
+
log(`edt: импорт завершён (${lastImportDetail})`);
|
|
454
|
+
if (importWarning !== undefined) {
|
|
455
|
+
log(`edt: импорт шёл на пределе памяти (${importWarning.used ?? '?'}/${importWarning.total ?? '?'} MB) — поднимите workspace.xmxImport`);
|
|
456
|
+
}
|
|
457
|
+
if (snapshot.forceImport === true) {
|
|
458
|
+
const { forceImport, ...rest } = snapshot;
|
|
459
|
+
writeWorkspaceSnapshot(buildDir, rest);
|
|
460
|
+
}
|
|
461
|
+
} else {
|
|
462
|
+
log('edt: импорт не требуется');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
writePrefs(buildDir, port, {
|
|
466
|
+
plainText: settings.plainText,
|
|
467
|
+
formScreenshots: settings.formScreenshots,
|
|
468
|
+
});
|
|
469
|
+
const runningPid = readPid(buildDir)?.pid;
|
|
470
|
+
if (runningPid !== undefined && (deps.alive ?? ((pid) => pidAlive(pid, ['1cedt'])))(runningPid)) {
|
|
471
|
+
log(`edt: уже запущен (pid ${runningPid})`);
|
|
472
|
+
} else {
|
|
473
|
+
const vm = (deps.resolveEdtVm ?? resolveEdtVm)(settings, detected.home, deps);
|
|
474
|
+
const required = (deps.requiredJava ?? requiredJava)(detected.home, { platform: process.platform });
|
|
475
|
+
log(`edt: Java ${vm ?? 'авто'}${required.major === undefined ? '' : ` (EDT требует ${required.major})`}`);
|
|
476
|
+
writeProgress(buildDir, 'edt', 'gui');
|
|
477
|
+
const gui = (deps.spawnDetached ?? spawnDetached)(
|
|
478
|
+
detected.bin,
|
|
479
|
+
guiArgs({
|
|
480
|
+
wsDir: snapshot.wsDir,
|
|
481
|
+
xmx: settings.xmxIde,
|
|
482
|
+
vm,
|
|
483
|
+
nativeFormBufferedLayoutRender: settings.formScreenshots,
|
|
484
|
+
}),
|
|
485
|
+
path.join(buildDir, 'logs', 'edt.log'),
|
|
486
|
+
root,
|
|
487
|
+
deps,
|
|
488
|
+
);
|
|
489
|
+
if (!gui?.pid) {
|
|
490
|
+
writeProgress(buildDir, 'edt', 'failed', 'не удалось запустить 1cedt');
|
|
491
|
+
writeStamp(buildDir, 'failed', deps.now?.());
|
|
492
|
+
log('edt: FAILED: не удалось запустить 1cedt');
|
|
493
|
+
return 1;
|
|
494
|
+
}
|
|
495
|
+
writePid(buildDir, gui.pid, deps.now?.());
|
|
496
|
+
log(`edt: запущен pid ${gui.pid} (workspace ${snapshot.wsDir})`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
removeStamp(buildDir, 'ready');
|
|
500
|
+
writeProgress(buildDir, 'edt', 'health');
|
|
501
|
+
log(`edt: жду /health на порту ${port} (до ${Math.round(settings.startTimeout / 60)} мин)`);
|
|
502
|
+
const ready = await (deps.waitReady ?? waitReady)(
|
|
503
|
+
{
|
|
504
|
+
port,
|
|
505
|
+
pid: readPid(buildDir)?.pid,
|
|
506
|
+
alive: (value) => (deps.alive ?? ((pid) => pidAlive(pid, ['1cedt'])))(value),
|
|
507
|
+
logPath: path.join(buildDir, 'logs', 'edt.log'),
|
|
508
|
+
timeoutMs: settings.startTimeout * 1000,
|
|
509
|
+
},
|
|
510
|
+
deps,
|
|
511
|
+
);
|
|
512
|
+
if (ready.ok === true) {
|
|
513
|
+
writeProgress(buildDir, 'edt', 'ready', undefined, deps.now?.(), importWarning === undefined ? undefined : { warning: importWarning });
|
|
514
|
+
writeStamp(buildDir, 'ready', deps.now?.());
|
|
515
|
+
removeStamp(buildDir, 'failed');
|
|
516
|
+
log('edt: READY');
|
|
517
|
+
return 0;
|
|
518
|
+
}
|
|
519
|
+
const detail =
|
|
520
|
+
ready.reason === 'process-exited'
|
|
521
|
+
? `процесс EDT завершился${ready.detail === undefined ? '' : `: ${ready.detail}`}`
|
|
522
|
+
: `health timeout${ready.detail === undefined ? '' : `: ${ready.detail}`}`;
|
|
523
|
+
writeProgress(buildDir, 'edt', 'failed', detail, deps.now?.(), importWarning === undefined ? undefined : { warning: importWarning });
|
|
524
|
+
writeStamp(buildDir, 'failed', deps.now?.());
|
|
525
|
+
log(`edt: FAILED: ${detail}`);
|
|
526
|
+
return 1;
|
|
527
|
+
} finally {
|
|
528
|
+
releaseLock(lock);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function writeImportScript(buildDir, projects) {
|
|
533
|
+
const file = filePath(buildDir, 'script');
|
|
534
|
+
writeFileSync(file, importScriptText(projects), 'utf8');
|
|
535
|
+
return file;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Ждёт /health до истечения таймаута. */
|
|
539
|
+
export async function waitHealth(port, timeoutMs, deps = {}) {
|
|
540
|
+
const probe = deps.probeHealth ?? probeHealth;
|
|
541
|
+
const delay = deps.sleep ?? sleep;
|
|
542
|
+
const pollMs = deps.pollMs ?? HEALTH_POLL_MS;
|
|
543
|
+
const deadline = Date.now() + timeoutMs;
|
|
544
|
+
while (Date.now() < deadline) {
|
|
545
|
+
if (await probe(port, deps)) return true;
|
|
546
|
+
await delay(pollMs);
|
|
547
|
+
}
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Ждёт готовности EDT и замечает смерть процесса: если pid умер, а /health
|
|
553
|
+
* молчит grace-период, возвращает причину сразу, не выжидая весь startTimeout.
|
|
554
|
+
* @returns {Promise<{ ok: boolean, reason?: 'process-exited'|'timeout', detail?: string }>}
|
|
555
|
+
*/
|
|
556
|
+
export async function waitReady({ port, pid, alive, logPath, timeoutMs }, deps = {}) {
|
|
557
|
+
const probe = deps.probeHealth ?? probeHealth;
|
|
558
|
+
const delay = deps.sleep ?? sleep;
|
|
559
|
+
const pollMs = deps.pollMs ?? HEALTH_POLL_MS;
|
|
560
|
+
const graceMs = deps.processGraceMs ?? 15_000;
|
|
561
|
+
const deadline = Date.now() + timeoutMs;
|
|
562
|
+
let deadSince;
|
|
563
|
+
const detail = () => (typeof logPath === 'string' ? tailFile(logPath, 300) ?? undefined : undefined);
|
|
564
|
+
|
|
565
|
+
while (Date.now() < deadline) {
|
|
566
|
+
if (await probe(port, deps)) return { ok: true };
|
|
567
|
+
if (pid !== undefined && typeof alive === 'function' && !alive(pid)) {
|
|
568
|
+
deadSince ??= Date.now();
|
|
569
|
+
if (Date.now() - deadSince >= graceMs) return { ok: false, reason: 'process-exited', detail: detail() };
|
|
570
|
+
} else {
|
|
571
|
+
deadSince = undefined;
|
|
572
|
+
}
|
|
573
|
+
await delay(pollMs);
|
|
574
|
+
}
|
|
575
|
+
return { ok: false, reason: 'timeout', detail: detail() };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Останавливает EDT этого worktree. */
|
|
579
|
+
export async function cmdStop(root, deps = {}) {
|
|
580
|
+
const buildDir = path.join(root, 'build');
|
|
581
|
+
const pidInfo = readPid(buildDir);
|
|
582
|
+
if (pidInfo === null) {
|
|
583
|
+
clearProgress(buildDir);
|
|
584
|
+
return { stopped: false, reason: 'нет pid — нечего останавливать' };
|
|
585
|
+
}
|
|
586
|
+
const alive = deps.alive ?? ((pid) => pidAlive(pid, ['1cedt']));
|
|
587
|
+
const pid = pidInfo.pid;
|
|
588
|
+
if (!alive(pid)) {
|
|
589
|
+
clearState(buildDir);
|
|
590
|
+
return { stopped: false, reason: `pid ${pid} уже не жив` };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
(deps.killTree ?? killTree)(pid, { force: false, ...deps });
|
|
594
|
+
const delay = deps.sleep ?? sleep;
|
|
595
|
+
const deadline = Date.now() + STOP_GRACE_MS;
|
|
596
|
+
while (alive(pid) && Date.now() < deadline) await delay(300);
|
|
597
|
+
if (alive(pid)) (deps.killTree ?? killTree)(pid, { force: true, ...deps });
|
|
598
|
+
clearState(buildDir);
|
|
599
|
+
return { stopped: true, pid };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function clearState(buildDir) {
|
|
603
|
+
for (const key of ['ready', 'failed', 'pid']) removeStamp(buildDir, key);
|
|
604
|
+
clearProgress(buildDir);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Останавливает окружения всех worktree репозитория. */
|
|
608
|
+
export async function cmdStopAll({ repo, root }, deps = {}) {
|
|
609
|
+
const run = deps.runGit ?? ((dir, args) => execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8' }));
|
|
610
|
+
const list = run(root ?? repo, ['worktree', 'list', '--porcelain']);
|
|
611
|
+
const roots = [];
|
|
612
|
+
for (const line of String(list).split(/\r?\n/)) {
|
|
613
|
+
if (line.startsWith('worktree ')) roots.push(line.slice('worktree '.length).trim());
|
|
614
|
+
}
|
|
615
|
+
const results = [];
|
|
616
|
+
for (const worktree of roots) {
|
|
617
|
+
const pid = readPid(path.join(worktree, 'build'));
|
|
618
|
+
if (pid === null) continue;
|
|
619
|
+
results.push({ worktree, ...(await cmdStop(worktree, deps)) });
|
|
620
|
+
}
|
|
621
|
+
return results;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Текстовый отчёт о состоянии (CLI и тесты). */
|
|
625
|
+
export function statusReport(root, deps = {}) {
|
|
626
|
+
const buildDir = path.join(root, 'build');
|
|
627
|
+
const snapshot = readWorkspaceSnapshot(buildDir);
|
|
628
|
+
const state = stateOf(buildDir, { alive: deps.alive });
|
|
629
|
+
const progress = state.progress?.edt;
|
|
630
|
+
const lines = [
|
|
631
|
+
`worktree: ${root}`,
|
|
632
|
+
`состояние: ${state.label}`,
|
|
633
|
+
`workspace: ${snapshot?.wsDir ?? path.join(buildDir, 'ws')}`,
|
|
634
|
+
`порт: ${state.port ?? '-'} pid: ${state.pid?.pid ?? '-'}`,
|
|
635
|
+
];
|
|
636
|
+
const settings = snapshot?.settings ?? normalizeWorkspaceSettings({});
|
|
637
|
+
const home = detectEdtHome(settings, { platform: process.platform, env: process.env });
|
|
638
|
+
const required = requiredJava(home.home, { platform: process.platform });
|
|
639
|
+
const vm = resolveEdtVm(settings, home.home, { platform: process.platform, env: process.env });
|
|
640
|
+
lines.push(`EDT: ${home.home ?? 'не найден'}`);
|
|
641
|
+
lines.push(`Java: ${vm ?? 'авто'}${required.major === undefined ? '' : ` (EDT требует ${required.major})`}`);
|
|
642
|
+
lines.push(`Plain text: ${settings.plainText === false ? 'нет' : 'да'}; скриншоты форм: ${settings.formScreenshots === false ? 'нет' : 'да'}`);
|
|
643
|
+
if (progress?.phase) {
|
|
644
|
+
lines.push(`фаза: ${progress.phase}${progress.since ? ` (${formatElapsed(progress.since)})` : ''}${progress.detail ? ` — ${progress.detail}` : ''}`);
|
|
645
|
+
}
|
|
646
|
+
if (state.imported?.projects) lines.push(`импортировано проектов: ${state.imported.projects.length}`);
|
|
647
|
+
lines.push(`логи: ${path.join(buildDir, 'logs')}`);
|
|
648
|
+
return { root, buildDir, snapshot, state, lines, data: { ...state, workspace: snapshot?.wsDir, root } };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Точка входа CLI. */
|
|
652
|
+
export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
653
|
+
const args = parseArgs(argv);
|
|
654
|
+
const root = args.root ?? process.cwd();
|
|
655
|
+
switch (args.action) {
|
|
656
|
+
case 'init':
|
|
657
|
+
return cmdInit(root, args, deps);
|
|
658
|
+
case 'run':
|
|
659
|
+
return cmdRun(root, args, deps);
|
|
660
|
+
case 'status': {
|
|
661
|
+
const report = statusReport(root, deps);
|
|
662
|
+
if (args.json) console.log(JSON.stringify(report.data, null, 2));
|
|
663
|
+
else for (const line of report.lines) console.log(line);
|
|
664
|
+
return 0;
|
|
665
|
+
}
|
|
666
|
+
case 'stop': {
|
|
667
|
+
const result = await cmdStop(root, deps);
|
|
668
|
+
console.log(result.stopped ? `остановлен pid ${result.pid}` : `не остановлен: ${result.reason}`);
|
|
669
|
+
return 0;
|
|
670
|
+
}
|
|
671
|
+
case 'stop-all': {
|
|
672
|
+
const results = await cmdStopAll({ repo: args.repo ?? root, root: args.root }, deps);
|
|
673
|
+
console.log(JSON.stringify(results, null, 2));
|
|
674
|
+
return 0;
|
|
675
|
+
}
|
|
676
|
+
default:
|
|
677
|
+
console.log('usage: node lib/workspace-run.mjs [init|run|status|stop|stop-all] [--root <dir>] [--repo <dir>] [--import] [--json]');
|
|
678
|
+
return 2;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
683
|
+
if (isMain) {
|
|
684
|
+
main()
|
|
685
|
+
.then((code) => process.exit(code ?? 0))
|
|
686
|
+
.catch((error) => {
|
|
687
|
+
console.error(`workspace-run: ${error?.message ?? error}`);
|
|
688
|
+
process.exit(1);
|
|
689
|
+
});
|
|
690
|
+
}
|