livedesk 0.1.385 → 0.1.387
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/bin/livedesk.js +8 -3
- package/bootstrap/legacy-client-update.js +1040 -0
- package/client/bin/livedesk-client-node.js +29 -42
- package/client/bin/livedesk-client.js +22 -10
- package/client/package.json +5 -5
- package/hub/src/live-desk-update.js +80 -126
- package/hub/src/remote-hub.js +22 -14
- package/package.json +6 -6
|
@@ -0,0 +1,1040 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
appendFileSync,
|
|
5
|
+
closeSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
openSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
writeFileSync
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import http from 'node:http';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_RUNTIME_PORT = 5179;
|
|
19
|
+
const TARGET_ATTEMPT_LIMIT = 3;
|
|
20
|
+
const RESTORE_ATTEMPT_LIMIT = 2;
|
|
21
|
+
const VERIFY_TIMEOUT_MS = 45_000;
|
|
22
|
+
const VERIFY_POLL_MS = 500;
|
|
23
|
+
const VERIFY_STABLE_MS = 5_000;
|
|
24
|
+
const PROCESS_ARGUMENT_QUERY_TIMEOUT_MS = 10_000;
|
|
25
|
+
|
|
26
|
+
const PRESERVED_SINGLE_FLAGS = new Set([
|
|
27
|
+
'--control',
|
|
28
|
+
'--no-control',
|
|
29
|
+
'--thumbnail',
|
|
30
|
+
'--no-thumbnail',
|
|
31
|
+
'--live',
|
|
32
|
+
'--no-live',
|
|
33
|
+
'--audio',
|
|
34
|
+
'--no-audio',
|
|
35
|
+
'--tasks',
|
|
36
|
+
'--no-tasks',
|
|
37
|
+
'--ai',
|
|
38
|
+
'--no-ai',
|
|
39
|
+
'--fake-ai',
|
|
40
|
+
'--fake-thumbnail',
|
|
41
|
+
'--trace-frames'
|
|
42
|
+
]);
|
|
43
|
+
const PRESERVED_VALUE_FLAGS = new Set([
|
|
44
|
+
'--name',
|
|
45
|
+
'--heartbeat',
|
|
46
|
+
'--files-dir',
|
|
47
|
+
'--ai-model',
|
|
48
|
+
'--transport'
|
|
49
|
+
]);
|
|
50
|
+
const JOINED_VALUE_FLAGS = new Set(['--name', '--files-dir']);
|
|
51
|
+
const SETTING_GROUPS = new Map([
|
|
52
|
+
['--control', 'control'],
|
|
53
|
+
['--no-control', 'control'],
|
|
54
|
+
['--thumbnail', 'thumbnail'],
|
|
55
|
+
['--no-thumbnail', 'thumbnail'],
|
|
56
|
+
['--live', 'live'],
|
|
57
|
+
['--no-live', 'live'],
|
|
58
|
+
['--audio', 'audio'],
|
|
59
|
+
['--no-audio', 'audio'],
|
|
60
|
+
['--tasks', 'tasks'],
|
|
61
|
+
['--no-tasks', 'tasks'],
|
|
62
|
+
['--ai', 'ai'],
|
|
63
|
+
['--no-ai', 'ai']
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
function cleanVersion(value) {
|
|
67
|
+
return String(value || '').trim().replace(/^v/i, '');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function positivePid(value) {
|
|
71
|
+
const parsed = Number(value);
|
|
72
|
+
return Number.isInteger(parsed) && parsed > 1 ? parsed : 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizePort(value) {
|
|
76
|
+
const parsed = Number(value);
|
|
77
|
+
return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535
|
|
78
|
+
? parsed
|
|
79
|
+
: DEFAULT_RUNTIME_PORT;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readManifest(relativePath) {
|
|
83
|
+
return JSON.parse(readFileSync(new URL(relativePath, import.meta.url), 'utf8'));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readConfig(env = process.env) {
|
|
87
|
+
const stateDir = String(env.LIVEDESK_STATE_DIR || join(os.homedir(), '.livedesk'));
|
|
88
|
+
return {
|
|
89
|
+
operationId: String(env.LIVEDESK_CLIENT_UPDATE_OPERATION_ID || '').trim(),
|
|
90
|
+
deviceId: String(env.LIVEDESK_DEVICE_ID || '').trim(),
|
|
91
|
+
targetVersion: cleanVersion(env.LIVEDESK_CLIENT_UPDATE_TARGET_VERSION),
|
|
92
|
+
targetProductVersion: cleanVersion(env.LIVEDESK_CLIENT_UPDATE_TARGET_PRODUCT_VERSION),
|
|
93
|
+
currentProductVersion: cleanVersion(env.LIVEDESK_CLIENT_UPDATE_CURRENT_PRODUCT_VERSION),
|
|
94
|
+
currentAgentVersion: cleanVersion(env.LIVEDESK_CLIENT_UPDATE_CURRENT_AGENT_VERSION),
|
|
95
|
+
launcherPid: positivePid(env.LIVEDESK_CLIENT_PARENT_PID),
|
|
96
|
+
agentPid: positivePid(env.LIVEDESK_CLIENT_UPDATE_AGENT_PID),
|
|
97
|
+
starterPid: positivePid(env.LIVEDESK_UPDATE_STARTER_PID),
|
|
98
|
+
runtimePort: normalizePort(env.LIVEDESK_CLIENT_RUNTIME_PORT || env.LIVEDESK_LOCAL_RUNTIME_PORT),
|
|
99
|
+
stateDir,
|
|
100
|
+
statePath: String(
|
|
101
|
+
env.LIVEDESK_LEGACY_CLIENT_UPDATE_STATE_PATH
|
|
102
|
+
|| env.LIVEDESK_CLIENT_UPDATE_STATE_PATH
|
|
103
|
+
|| join(stateDir, 'client-update-legacy.json')
|
|
104
|
+
),
|
|
105
|
+
restartArgs: []
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function createLogger(config, writeLog = null) {
|
|
110
|
+
if (typeof writeLog === 'function') return writeLog;
|
|
111
|
+
const logPath = join(config.stateDir, 'client-update-legacy.log');
|
|
112
|
+
return message => {
|
|
113
|
+
try {
|
|
114
|
+
mkdirSync(config.stateDir, { recursive: true });
|
|
115
|
+
appendFileSync(logPath, `${new Date().toISOString()} ${message}\n`, 'utf8');
|
|
116
|
+
} catch {
|
|
117
|
+
// A read-only profile must not stop recovery.
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseWindowsCommandLine(commandLine) {
|
|
123
|
+
const source = String(commandLine || '');
|
|
124
|
+
const args = [];
|
|
125
|
+
let current = '';
|
|
126
|
+
let quoted = false;
|
|
127
|
+
let backslashes = 0;
|
|
128
|
+
let started = false;
|
|
129
|
+
|
|
130
|
+
const flushBackslashes = count => {
|
|
131
|
+
if (count > 0) current += '\\'.repeat(count);
|
|
132
|
+
};
|
|
133
|
+
const push = () => {
|
|
134
|
+
if (!started) return;
|
|
135
|
+
flushBackslashes(backslashes);
|
|
136
|
+
backslashes = 0;
|
|
137
|
+
args.push(current);
|
|
138
|
+
current = '';
|
|
139
|
+
started = false;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
143
|
+
const character = source[index];
|
|
144
|
+
if (character === '\\') {
|
|
145
|
+
backslashes += 1;
|
|
146
|
+
started = true;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (character === '"') {
|
|
150
|
+
current += '\\'.repeat(Math.floor(backslashes / 2));
|
|
151
|
+
if (backslashes % 2 === 1) {
|
|
152
|
+
current += '"';
|
|
153
|
+
} else {
|
|
154
|
+
quoted = !quoted;
|
|
155
|
+
}
|
|
156
|
+
backslashes = 0;
|
|
157
|
+
started = true;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
flushBackslashes(backslashes);
|
|
161
|
+
backslashes = 0;
|
|
162
|
+
if (!quoted && /\s/.test(character)) {
|
|
163
|
+
push();
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
current += character;
|
|
167
|
+
started = true;
|
|
168
|
+
}
|
|
169
|
+
push();
|
|
170
|
+
return args;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseShellCommandLine(commandLine) {
|
|
174
|
+
const source = String(commandLine || '');
|
|
175
|
+
const args = [];
|
|
176
|
+
let current = '';
|
|
177
|
+
let quote = '';
|
|
178
|
+
let escaped = false;
|
|
179
|
+
let started = false;
|
|
180
|
+
const push = () => {
|
|
181
|
+
if (!started) return;
|
|
182
|
+
args.push(current);
|
|
183
|
+
current = '';
|
|
184
|
+
started = false;
|
|
185
|
+
};
|
|
186
|
+
for (const character of source) {
|
|
187
|
+
if (escaped) {
|
|
188
|
+
current += character;
|
|
189
|
+
escaped = false;
|
|
190
|
+
started = true;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (character === '\\' && quote !== "'") {
|
|
194
|
+
escaped = true;
|
|
195
|
+
started = true;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (quote) {
|
|
199
|
+
if (character === quote) {
|
|
200
|
+
quote = '';
|
|
201
|
+
} else {
|
|
202
|
+
current += character;
|
|
203
|
+
}
|
|
204
|
+
started = true;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (character === '"' || character === "'") {
|
|
208
|
+
quote = character;
|
|
209
|
+
started = true;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (/\s/.test(character)) {
|
|
213
|
+
push();
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
current += character;
|
|
217
|
+
started = true;
|
|
218
|
+
}
|
|
219
|
+
if (escaped) current += '\\';
|
|
220
|
+
push();
|
|
221
|
+
return args;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function decodePreservedArgs(env) {
|
|
225
|
+
const encoded = String(env.LIVEDESK_CLIENT_UPDATE_ARGS_BASE64 || '').trim();
|
|
226
|
+
if (!encoded) return null;
|
|
227
|
+
try {
|
|
228
|
+
const decoded = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'));
|
|
229
|
+
if (!Array.isArray(decoded)) return null;
|
|
230
|
+
return decoded.map(value => String(value));
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function processArgumentsFromWindows(pid) {
|
|
237
|
+
const script = [
|
|
238
|
+
'$ErrorActionPreference = "Stop"',
|
|
239
|
+
'[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)',
|
|
240
|
+
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"`,
|
|
241
|
+
'if ($null -eq $p -or [string]::IsNullOrWhiteSpace($p.CommandLine)) { exit 3 }',
|
|
242
|
+
'[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$p.CommandLine))'
|
|
243
|
+
].join(';');
|
|
244
|
+
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
|
245
|
+
const result = spawnSync(
|
|
246
|
+
'powershell.exe',
|
|
247
|
+
['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
|
|
248
|
+
{
|
|
249
|
+
encoding: 'utf8',
|
|
250
|
+
timeout: PROCESS_ARGUMENT_QUERY_TIMEOUT_MS,
|
|
251
|
+
windowsHide: true
|
|
252
|
+
}
|
|
253
|
+
);
|
|
254
|
+
if (result.error || result.status !== 0) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`Windows process argument query failed for pid ${pid}: `
|
|
257
|
+
+ `${result.error?.message || String(result.stderr || '').trim() || `exit ${result.status}`}`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
const commandLine = Buffer.from(String(result.stdout || '').trim(), 'base64').toString('utf8');
|
|
261
|
+
const args = parseWindowsCommandLine(commandLine);
|
|
262
|
+
if (args.length === 0) throw new Error(`Windows process ${pid} has no readable arguments`);
|
|
263
|
+
return args;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function processArgumentsFromProc(pid) {
|
|
267
|
+
const bytes = readFileSync(`/proc/${pid}/cmdline`);
|
|
268
|
+
const args = bytes
|
|
269
|
+
.toString('utf8')
|
|
270
|
+
.split('\0')
|
|
271
|
+
.filter(value => value.length > 0);
|
|
272
|
+
if (args.length === 0) throw new Error(`Process ${pid} has no readable arguments`);
|
|
273
|
+
return args;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function processArgumentsFromPs(pid) {
|
|
277
|
+
const result = spawnSync('ps', ['-ww', '-p', String(pid), '-o', 'command='], {
|
|
278
|
+
encoding: 'utf8',
|
|
279
|
+
timeout: PROCESS_ARGUMENT_QUERY_TIMEOUT_MS
|
|
280
|
+
});
|
|
281
|
+
if (result.error || result.status !== 0 || !String(result.stdout || '').trim()) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
`ps argument query failed for pid ${pid}: `
|
|
284
|
+
+ `${result.error?.message || String(result.stderr || '').trim() || `exit ${result.status}`}`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
return parseShellCommandLine(result.stdout);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function readLegacyProcessArguments(pid, platform = process.platform) {
|
|
291
|
+
const processId = positivePid(pid);
|
|
292
|
+
if (!processId) throw new Error(`Invalid process id for argument snapshot: ${pid}`);
|
|
293
|
+
if (platform === 'win32') return processArgumentsFromWindows(processId);
|
|
294
|
+
if (platform === 'linux' && existsSync(`/proc/${processId}/cmdline`)) {
|
|
295
|
+
return processArgumentsFromProc(processId);
|
|
296
|
+
}
|
|
297
|
+
if (platform === 'darwin' || platform === 'linux' || platform === 'freebsd') {
|
|
298
|
+
return processArgumentsFromPs(processId);
|
|
299
|
+
}
|
|
300
|
+
throw new Error(`LiveDesk cannot snapshot Client arguments on ${platform}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function readOptionValue(args, index, joinUntilFlag) {
|
|
304
|
+
const values = [];
|
|
305
|
+
for (let cursor = index + 1; cursor < args.length; cursor += 1) {
|
|
306
|
+
const value = String(args[cursor] || '');
|
|
307
|
+
if (!value || value === '--') continue;
|
|
308
|
+
if (value.startsWith('--')) break;
|
|
309
|
+
values.push(value);
|
|
310
|
+
if (!joinUntilFlag) break;
|
|
311
|
+
}
|
|
312
|
+
return values.join(' ');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function collectPreservedOptions(sources) {
|
|
316
|
+
const selected = new Map();
|
|
317
|
+
for (const source of sources) {
|
|
318
|
+
const args = Array.isArray(source) ? source.map(value => String(value)) : [];
|
|
319
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
320
|
+
const token = args[index];
|
|
321
|
+
const equalsAt = token.indexOf('=');
|
|
322
|
+
const flag = equalsAt > 0 ? token.slice(0, equalsAt) : token;
|
|
323
|
+
if (PRESERVED_SINGLE_FLAGS.has(flag)) {
|
|
324
|
+
selected.set(SETTING_GROUPS.get(flag) || flag, [flag]);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (!PRESERVED_VALUE_FLAGS.has(flag)) continue;
|
|
328
|
+
const value = equalsAt > 0
|
|
329
|
+
? token.slice(equalsAt + 1)
|
|
330
|
+
: readOptionValue(args, index, JOINED_VALUE_FLAGS.has(flag));
|
|
331
|
+
if (!value) continue;
|
|
332
|
+
selected.set(flag, [flag, value]);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return [...selected.values()].flat();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function findOptionValue(args, flag) {
|
|
339
|
+
const source = Array.isArray(args) ? args.map(value => String(value)) : [];
|
|
340
|
+
let found = '';
|
|
341
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
342
|
+
const token = source[index];
|
|
343
|
+
if (token.startsWith(`${flag}=`)) {
|
|
344
|
+
found = token.slice(flag.length + 1);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (token === flag && source[index + 1] && !String(source[index + 1]).startsWith('--')) {
|
|
348
|
+
found = String(source[index + 1]);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return found;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function normalizeEngine(value) {
|
|
355
|
+
const engine = String(value || '').trim().toLowerCase();
|
|
356
|
+
if (engine === 'c#' || engine === 'csharp' || engine === 'fast') return 'fast';
|
|
357
|
+
if (engine === 'js' || engine === 'legacy' || engine === 'node') return 'node';
|
|
358
|
+
return 'auto';
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function buildLegacyClientRestartConfiguration({
|
|
362
|
+
env = process.env,
|
|
363
|
+
launcherArgs = [],
|
|
364
|
+
agentArgs = [],
|
|
365
|
+
preservedArgs = decodePreservedArgs(env)
|
|
366
|
+
} = {}) {
|
|
367
|
+
const sources = [preservedArgs || [], launcherArgs, agentArgs];
|
|
368
|
+
const restartArgs = collectPreservedOptions(sources);
|
|
369
|
+
const commandLinePort = findOptionValue(launcherArgs, '--auth-port')
|
|
370
|
+
|| findOptionValue(preservedArgs, '--auth-port');
|
|
371
|
+
const runtimePort = normalizePort(
|
|
372
|
+
commandLinePort
|
|
373
|
+
|| env.LIVEDESK_CLIENT_RUNTIME_PORT
|
|
374
|
+
|| env.LIVEDESK_CLIENT_AUTH_PORT
|
|
375
|
+
|| env.LIVEDESK_LOCAL_RUNTIME_PORT
|
|
376
|
+
);
|
|
377
|
+
const commandLineEngine = findOptionValue(launcherArgs, '--engine')
|
|
378
|
+
|| findOptionValue(preservedArgs, '--engine');
|
|
379
|
+
const engineFlag = [...launcherArgs, ...(preservedArgs || [])].find(value => (
|
|
380
|
+
value === '--fast' || value === '--csharp' || value === '--node'
|
|
381
|
+
));
|
|
382
|
+
const engine = normalizeEngine(
|
|
383
|
+
env.LIVEDESK_CLIENT_ENGINE
|
|
384
|
+
|| commandLineEngine
|
|
385
|
+
|| (engineFlag === '--node' ? 'node' : engineFlag ? 'fast' : 'auto')
|
|
386
|
+
);
|
|
387
|
+
restartArgs.push('--engine', engine, '--auth-port', String(runtimePort));
|
|
388
|
+
return { restartArgs, runtimePort, engine };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function captureRestartConfiguration(config, env, readArguments = readLegacyProcessArguments) {
|
|
392
|
+
const preservedArgs = decodePreservedArgs(env);
|
|
393
|
+
if (preservedArgs) {
|
|
394
|
+
return buildLegacyClientRestartConfiguration({ env, preservedArgs });
|
|
395
|
+
}
|
|
396
|
+
let launcherArgs;
|
|
397
|
+
let agentArgs;
|
|
398
|
+
try {
|
|
399
|
+
launcherArgs = readArguments(config.launcherPid);
|
|
400
|
+
agentArgs = readArguments(config.agentPid);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
throw new Error(
|
|
403
|
+
`LiveDesk could not freeze the current Client options before update; `
|
|
404
|
+
+ `the existing Client was left running. ${error?.message || error}`
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
return buildLegacyClientRestartConfiguration({ env, launcherArgs, agentArgs });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function readUpdateState(config) {
|
|
411
|
+
try {
|
|
412
|
+
return JSON.parse(readFileSync(config.statePath, 'utf8'));
|
|
413
|
+
} catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function proofStatePath(config, attemptId) {
|
|
419
|
+
const safeAttemptId = String(attemptId || '')
|
|
420
|
+
.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
421
|
+
.slice(0, 160);
|
|
422
|
+
return join(dirname(config.statePath), `client-update-proof-${safeAttemptId}.json`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function writeUpdateState(config, stage, details = {}) {
|
|
426
|
+
mkdirSync(dirname(config.statePath), { recursive: true });
|
|
427
|
+
const previous = readUpdateState(config);
|
|
428
|
+
const next = {
|
|
429
|
+
...(previous && previous.operationId === config.operationId ? previous : {}),
|
|
430
|
+
operationId: config.operationId,
|
|
431
|
+
stage,
|
|
432
|
+
targetVersion: config.targetVersion,
|
|
433
|
+
targetProductVersion: config.targetProductVersion,
|
|
434
|
+
targetAgentVersion: config.targetVersion,
|
|
435
|
+
currentProductVersion: config.currentProductVersion,
|
|
436
|
+
supervisorPid: process.pid,
|
|
437
|
+
updatedAt: new Date().toISOString(),
|
|
438
|
+
...details
|
|
439
|
+
};
|
|
440
|
+
const temporaryPath = `${config.statePath}.${process.pid}.${randomBytes(5).toString('hex')}.tmp`;
|
|
441
|
+
try {
|
|
442
|
+
writeFileSync(temporaryPath, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
443
|
+
renameSync(temporaryPath, config.statePath);
|
|
444
|
+
} finally {
|
|
445
|
+
rmSync(temporaryPath, { force: true });
|
|
446
|
+
}
|
|
447
|
+
return next;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function isAlive(pid) {
|
|
451
|
+
if (pid <= 1) return false;
|
|
452
|
+
try {
|
|
453
|
+
process.kill(pid, 0);
|
|
454
|
+
return true;
|
|
455
|
+
} catch (error) {
|
|
456
|
+
return error?.code === 'EPERM';
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function sleep(milliseconds) {
|
|
461
|
+
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function waitForExit(pid, timeoutMs, sleepImpl = sleep, aliveImpl = isAlive) {
|
|
465
|
+
if (pid <= 1) return true;
|
|
466
|
+
const deadline = Date.now() + timeoutMs;
|
|
467
|
+
while (Date.now() < deadline) {
|
|
468
|
+
if (!aliveImpl(pid)) return true;
|
|
469
|
+
await sleepImpl(200);
|
|
470
|
+
}
|
|
471
|
+
return !aliveImpl(pid);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function requestRuntimeStatus(port, timeoutMs = 1500) {
|
|
475
|
+
return new Promise((resolve, reject) => {
|
|
476
|
+
const request = http.get({
|
|
477
|
+
host: '127.0.0.1',
|
|
478
|
+
port,
|
|
479
|
+
path: '/api/runtime/status',
|
|
480
|
+
headers: { Accept: 'application/json' }
|
|
481
|
+
}, response => {
|
|
482
|
+
let body = '';
|
|
483
|
+
response.setEncoding('utf8');
|
|
484
|
+
response.on('data', chunk => {
|
|
485
|
+
body += chunk;
|
|
486
|
+
if (body.length > 1024 * 1024) request.destroy(new Error('runtime status response is too large'));
|
|
487
|
+
});
|
|
488
|
+
response.once('end', () => {
|
|
489
|
+
if (response.statusCode !== 200) {
|
|
490
|
+
reject(new Error(`runtime status returned HTTP ${response.statusCode || 0}`));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
try {
|
|
494
|
+
resolve(JSON.parse(body));
|
|
495
|
+
} catch {
|
|
496
|
+
reject(new Error('runtime status returned invalid JSON'));
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
request.setTimeout(timeoutMs, () => request.destroy(new Error('runtime status timed out')));
|
|
501
|
+
request.once('error', reject);
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function validateLegacyClientReplacementStatus(
|
|
506
|
+
status,
|
|
507
|
+
{ deviceId, productVersion, previousRuntimePid = 0 }
|
|
508
|
+
) {
|
|
509
|
+
const runtimePid = positivePid(status?.runtimePid);
|
|
510
|
+
const agentPid = positivePid(status?.agent?.pid);
|
|
511
|
+
if (status?.ok !== true || status?.role !== 'client' || status?.runtimeStarted !== true) {
|
|
512
|
+
throw new Error('replacement Client runtime is not ready');
|
|
513
|
+
}
|
|
514
|
+
if (String(status?.deviceId || '') !== String(deviceId || '')) {
|
|
515
|
+
throw new Error(`replacement device mismatch expected=${deviceId || 'unknown'} actual=${status?.deviceId || 'unknown'}`);
|
|
516
|
+
}
|
|
517
|
+
if (cleanVersion(status?.appVersion) !== cleanVersion(productVersion)) {
|
|
518
|
+
throw new Error(`replacement product mismatch expected=${productVersion || 'unknown'} actual=${status?.appVersion || 'unknown'}`);
|
|
519
|
+
}
|
|
520
|
+
if (runtimePid <= 1 || (previousRuntimePid > 1 && runtimePid === previousRuntimePid)) {
|
|
521
|
+
throw new Error(`replacement runtime PID is invalid or unchanged (${runtimePid || 0})`);
|
|
522
|
+
}
|
|
523
|
+
if (status?.agent?.state !== 'running' || agentPid <= 1) {
|
|
524
|
+
throw new Error('replacement Agent is not running');
|
|
525
|
+
}
|
|
526
|
+
return { runtimePid, agentPid, appVersion: cleanVersion(status.appVersion) };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function validateLegacyClientConnectedProof(
|
|
530
|
+
state,
|
|
531
|
+
{
|
|
532
|
+
operationId,
|
|
533
|
+
attemptId,
|
|
534
|
+
productVersion,
|
|
535
|
+
agentVersion,
|
|
536
|
+
previousRuntimePid = 0
|
|
537
|
+
}
|
|
538
|
+
) {
|
|
539
|
+
const launcherPid = positivePid(state?.launcherPid);
|
|
540
|
+
const agentPid = positivePid(state?.agentPid);
|
|
541
|
+
if (state?.stage !== 'connected' || state?.restartVerified !== true) {
|
|
542
|
+
throw new Error('replacement Agent has not published connected proof');
|
|
543
|
+
}
|
|
544
|
+
if (String(state?.operationId || '') !== String(operationId || '')
|
|
545
|
+
|| String(state?.attemptId || '') !== String(attemptId || '')) {
|
|
546
|
+
throw new Error('replacement connected proof belongs to another operation or attempt');
|
|
547
|
+
}
|
|
548
|
+
if (cleanVersion(state?.productVersion) !== cleanVersion(productVersion)) {
|
|
549
|
+
throw new Error(
|
|
550
|
+
`replacement proof product mismatch expected=${productVersion || 'unknown'} `
|
|
551
|
+
+ `actual=${state?.productVersion || 'unknown'}`
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
if (cleanVersion(state?.agentVersion) !== cleanVersion(agentVersion)) {
|
|
555
|
+
throw new Error(
|
|
556
|
+
`replacement proof Agent mismatch expected=${agentVersion || 'unknown'} `
|
|
557
|
+
+ `actual=${state?.agentVersion || 'unknown'}`
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
if (launcherPid <= 1 || (previousRuntimePid > 1 && launcherPid === previousRuntimePid)) {
|
|
561
|
+
throw new Error(`replacement proof launcher PID is invalid or unchanged (${launcherPid || 0})`);
|
|
562
|
+
}
|
|
563
|
+
if (agentPid <= 1) throw new Error('replacement proof has no Agent PID');
|
|
564
|
+
return {
|
|
565
|
+
launcherPid,
|
|
566
|
+
agentPid,
|
|
567
|
+
productVersion: cleanVersion(state.productVersion),
|
|
568
|
+
agentVersion: cleanVersion(state.agentVersion)
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function resolveNpxInvocation(args, env = process.env) {
|
|
573
|
+
const nodeDirectory = dirname(process.execPath);
|
|
574
|
+
const npmExecPath = String(env.npm_execpath || env.NPM_EXECPATH || '').trim();
|
|
575
|
+
const cliCandidates = [
|
|
576
|
+
env.LIVEDESK_NPX_CLI_PATH,
|
|
577
|
+
npmExecPath ? join(dirname(npmExecPath), 'npx-cli.js') : '',
|
|
578
|
+
env.LIVEDESK_NPX_EXECUTABLE
|
|
579
|
+
? join(dirname(env.LIVEDESK_NPX_EXECUTABLE), 'node_modules', 'npm', 'bin', 'npx-cli.js')
|
|
580
|
+
: '',
|
|
581
|
+
join(nodeDirectory, 'node_modules', 'npm', 'bin', 'npx-cli.js')
|
|
582
|
+
].filter(Boolean);
|
|
583
|
+
const npxCli = cliCandidates.find(candidate => existsSync(candidate));
|
|
584
|
+
if (npxCli) return { command: process.execPath, args: [npxCli, ...args] };
|
|
585
|
+
|
|
586
|
+
const executableCandidates = process.platform === 'win32'
|
|
587
|
+
? [env.LIVEDESK_NPX_EXECUTABLE, join(nodeDirectory, 'npx.cmd'), 'npx.cmd']
|
|
588
|
+
: [env.LIVEDESK_NPX_EXECUTABLE, join(nodeDirectory, 'npx'), 'npx'];
|
|
589
|
+
const npx = executableCandidates.find(candidate => candidate && (!candidate.includes('/') && !candidate.includes('\\') || existsSync(candidate)))
|
|
590
|
+
|| executableCandidates.at(-1);
|
|
591
|
+
if (!npx) throw new Error('LiveDesk npx executable was not found');
|
|
592
|
+
if (process.platform !== 'win32') return { command: npx, args };
|
|
593
|
+
|
|
594
|
+
const quote = value => `"${String(value).replaceAll('"', '""')}"`;
|
|
595
|
+
return {
|
|
596
|
+
command: process.env.ComSpec || 'cmd.exe',
|
|
597
|
+
args: ['/d', '/s', '/c', `call ${quote(npx)} ${args.map(quote).join(' ')}`]
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function spawnReplacement(version, preference, env = process.env, restartArgs = [], outputPath = '') {
|
|
602
|
+
const packageArgs = ['-y', preference, `livedesk@${version}`];
|
|
603
|
+
const roleArgs = [
|
|
604
|
+
'--force-role',
|
|
605
|
+
'client',
|
|
606
|
+
'--no-login',
|
|
607
|
+
'--no-open',
|
|
608
|
+
...restartArgs,
|
|
609
|
+
'--device-id',
|
|
610
|
+
env.LIVEDESK_DEVICE_ID
|
|
611
|
+
];
|
|
612
|
+
const invocation = resolveNpxInvocation([...packageArgs, ...roleArgs], env);
|
|
613
|
+
let outputFd = null;
|
|
614
|
+
if (outputPath) {
|
|
615
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
616
|
+
outputFd = openSync(outputPath, 'a');
|
|
617
|
+
}
|
|
618
|
+
let child;
|
|
619
|
+
try {
|
|
620
|
+
child = spawn(invocation.command, invocation.args, {
|
|
621
|
+
env,
|
|
622
|
+
detached: true,
|
|
623
|
+
stdio: outputFd === null ? 'ignore' : ['ignore', outputFd, outputFd],
|
|
624
|
+
windowsHide: true
|
|
625
|
+
});
|
|
626
|
+
} finally {
|
|
627
|
+
if (outputFd !== null) closeSync(outputFd);
|
|
628
|
+
}
|
|
629
|
+
return new Promise((resolve, reject) => {
|
|
630
|
+
child.once('error', reject);
|
|
631
|
+
child.once('spawn', () => resolve(child));
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function hasLegacyConnectedOutput(outputPath, deviceId) {
|
|
636
|
+
if (!outputPath || !existsSync(outputPath)) return false;
|
|
637
|
+
let output = '';
|
|
638
|
+
try {
|
|
639
|
+
output = readFileSync(outputPath, 'utf8').slice(-256 * 1024);
|
|
640
|
+
} catch {
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
const escapedDeviceId = String(deviceId || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
644
|
+
if (!escapedDeviceId) return false;
|
|
645
|
+
return new RegExp(
|
|
646
|
+
`Connected to LiveDesk Hub as [^\\r\\n]*\\(${escapedDeviceId}\\)(?:\\s+via\\s+\\S+)?`
|
|
647
|
+
).test(output);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function terminateReplacementTree(child) {
|
|
651
|
+
const pid = positivePid(child?.pid);
|
|
652
|
+
if (pid <= 1) return;
|
|
653
|
+
if (process.platform === 'win32') {
|
|
654
|
+
await new Promise(resolve => {
|
|
655
|
+
const killer = spawn('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
|
|
656
|
+
windowsHide: true,
|
|
657
|
+
stdio: 'ignore'
|
|
658
|
+
});
|
|
659
|
+
killer.once('error', resolve);
|
|
660
|
+
killer.once('exit', resolve);
|
|
661
|
+
});
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
try {
|
|
665
|
+
process.kill(-pid, 'SIGKILL');
|
|
666
|
+
} catch {
|
|
667
|
+
try {
|
|
668
|
+
child.kill('SIGKILL');
|
|
669
|
+
} catch {
|
|
670
|
+
// The failed replacement already exited.
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function terminateOldRuntime(config, { sleepImpl = sleep, aliveImpl = isAlive } = {}) {
|
|
676
|
+
if (config.launcherPid <= 1) throw new Error('legacy update has no valid launcher PID');
|
|
677
|
+
const victims = [...new Set([config.launcherPid, config.agentPid].filter(pid => pid > 1 && pid !== process.pid))];
|
|
678
|
+
for (const pid of victims) {
|
|
679
|
+
try {
|
|
680
|
+
process.kill(pid, 'SIGTERM');
|
|
681
|
+
} catch {
|
|
682
|
+
// Already stopped.
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
await sleepImpl(2500);
|
|
686
|
+
for (const pid of victims) {
|
|
687
|
+
if (!aliveImpl(pid)) continue;
|
|
688
|
+
try {
|
|
689
|
+
process.kill(pid, 'SIGKILL');
|
|
690
|
+
} catch {
|
|
691
|
+
// Already stopped.
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
const launcherExited = await waitForExit(config.launcherPid, 15_000, sleepImpl, aliveImpl);
|
|
695
|
+
const agentExited = await waitForExit(config.agentPid, 15_000, sleepImpl, aliveImpl);
|
|
696
|
+
if (!launcherExited || !agentExited) {
|
|
697
|
+
throw new Error(`old runtime did not exit launcher=${launcherExited} agent=${agentExited}`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export async function launchAndVerifyLegacyClientReplacement(
|
|
702
|
+
config,
|
|
703
|
+
version,
|
|
704
|
+
agentVersion,
|
|
705
|
+
kind,
|
|
706
|
+
attempt,
|
|
707
|
+
preference,
|
|
708
|
+
options = {}
|
|
709
|
+
) {
|
|
710
|
+
const sleepImpl = options.sleepImpl || sleep;
|
|
711
|
+
const requestStatus = options.requestStatus || requestRuntimeStatus;
|
|
712
|
+
const writeState = options.writeUpdateState || writeUpdateState;
|
|
713
|
+
const aliveImpl = options.aliveImpl || isAlive;
|
|
714
|
+
const now = options.now || Date.now;
|
|
715
|
+
const log = options.log || (() => undefined);
|
|
716
|
+
const attemptId = String(
|
|
717
|
+
options.attemptId
|
|
718
|
+
|| `${config.operationId}-${kind}-${attempt}-${randomBytes(5).toString('hex')}`
|
|
719
|
+
);
|
|
720
|
+
const proofConfig = {
|
|
721
|
+
...config,
|
|
722
|
+
statePath: String(options.proofStatePath || proofStatePath(config, attemptId))
|
|
723
|
+
};
|
|
724
|
+
const readProofState = options.readUpdateState
|
|
725
|
+
? () => options.readUpdateState(proofConfig)
|
|
726
|
+
: () => readUpdateState(proofConfig);
|
|
727
|
+
if (!options.readUpdateState) rmSync(proofConfig.statePath, { force: true });
|
|
728
|
+
const outputPath = String(
|
|
729
|
+
options.outputPath
|
|
730
|
+
|| (kind === 'restore'
|
|
731
|
+
? join(
|
|
732
|
+
config.stateDir,
|
|
733
|
+
`client-update-${attemptId.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 160)}.log`
|
|
734
|
+
)
|
|
735
|
+
: '')
|
|
736
|
+
);
|
|
737
|
+
const replacementEnv = {
|
|
738
|
+
...(options.env || process.env),
|
|
739
|
+
LIVEDESK_CLIENT_UPDATE_OPERATION_ID: config.operationId,
|
|
740
|
+
LIVEDESK_CLIENT_UPDATE_ATTEMPT_ID: attemptId,
|
|
741
|
+
LIVEDESK_CLIENT_UPDATE_ATTEMPT_KIND: kind,
|
|
742
|
+
LIVEDESK_CLIENT_UPDATE_ATTEMPT_NUMBER: String(attempt),
|
|
743
|
+
LIVEDESK_CLIENT_UPDATE_TARGET_PRODUCT_VERSION: version,
|
|
744
|
+
LIVEDESK_CLIENT_UPDATE_TARGET_AGENT_VERSION: agentVersion,
|
|
745
|
+
LIVEDESK_CLIENT_UPDATE_TARGET_VERSION: agentVersion,
|
|
746
|
+
LIVEDESK_CLIENT_UPDATE_STATE_PATH: proofConfig.statePath,
|
|
747
|
+
LIVEDESK_CLIENT_RUNTIME_PORT: String(config.runtimePort),
|
|
748
|
+
LIVEDESK_DEVICE_ID: config.deviceId,
|
|
749
|
+
LIVEDESK_SKIP_BROWSER_OPEN: '1'
|
|
750
|
+
};
|
|
751
|
+
writeState(config, `${kind}-launching`, {
|
|
752
|
+
attemptId,
|
|
753
|
+
attemptKind: kind,
|
|
754
|
+
attemptNumber: attempt,
|
|
755
|
+
expectedProductVersion: version,
|
|
756
|
+
expectedAgentVersion: agentVersion,
|
|
757
|
+
outputPath,
|
|
758
|
+
restartVerified: false,
|
|
759
|
+
error: ''
|
|
760
|
+
});
|
|
761
|
+
const child = await (options.spawnReplacement || spawnReplacement)(
|
|
762
|
+
version,
|
|
763
|
+
preference,
|
|
764
|
+
replacementEnv,
|
|
765
|
+
config.restartArgs,
|
|
766
|
+
outputPath
|
|
767
|
+
);
|
|
768
|
+
const pid = positivePid(child?.pid);
|
|
769
|
+
log(`${kind} attempt=${attempt} id=${attemptId} spawned pid=${pid} version=${version}/${agentVersion}`);
|
|
770
|
+
// The replacement can reach the Hub and publish its connected proof before
|
|
771
|
+
// spawnReplacement resolves. Do not write a post-spawn lifecycle state here:
|
|
772
|
+
// it could overwrite that one-shot proof and turn a successful restart into
|
|
773
|
+
// a false timeout. The pre-spawn attempt state already owns this attempt.
|
|
774
|
+
const deadline = now() + (options.verifyTimeoutMs ?? VERIFY_TIMEOUT_MS);
|
|
775
|
+
let lastError = null;
|
|
776
|
+
let verifiedSince = null;
|
|
777
|
+
let lastVerified = null;
|
|
778
|
+
let terminalProofFailure = false;
|
|
779
|
+
while (now() < deadline) {
|
|
780
|
+
try {
|
|
781
|
+
const state = readProofState();
|
|
782
|
+
let proof;
|
|
783
|
+
let proofSource = 'agent-state';
|
|
784
|
+
try {
|
|
785
|
+
proof = validateLegacyClientConnectedProof(state, {
|
|
786
|
+
operationId: config.operationId,
|
|
787
|
+
attemptId,
|
|
788
|
+
productVersion: version,
|
|
789
|
+
agentVersion,
|
|
790
|
+
previousRuntimePid: config.launcherPid
|
|
791
|
+
});
|
|
792
|
+
} catch (proofError) {
|
|
793
|
+
const restoreWelcomeObserved = kind === 'restore'
|
|
794
|
+
&& (options.hasLegacyConnectedOutput || hasLegacyConnectedOutput)(
|
|
795
|
+
outputPath,
|
|
796
|
+
config.deviceId
|
|
797
|
+
);
|
|
798
|
+
if (!restoreWelcomeObserved) throw proofError;
|
|
799
|
+
proof = null;
|
|
800
|
+
proofSource = 'legacy-welcome-output';
|
|
801
|
+
}
|
|
802
|
+
const status = await requestStatus(config.runtimePort);
|
|
803
|
+
const verified = validateLegacyClientReplacementStatus(status, {
|
|
804
|
+
deviceId: config.deviceId,
|
|
805
|
+
productVersion: version,
|
|
806
|
+
previousRuntimePid: config.launcherPid
|
|
807
|
+
});
|
|
808
|
+
if (!proof) {
|
|
809
|
+
proof = {
|
|
810
|
+
launcherPid: verified.runtimePid,
|
|
811
|
+
agentPid: verified.agentPid,
|
|
812
|
+
productVersion: verified.appVersion,
|
|
813
|
+
agentVersion
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
if (proof.launcherPid !== verified.runtimePid || proof.agentPid !== verified.agentPid) {
|
|
817
|
+
throw new Error(
|
|
818
|
+
`replacement connected proof PIDs do not match local runtime `
|
|
819
|
+
+ `proof=${proof.launcherPid}/${proof.agentPid} `
|
|
820
|
+
+ `runtime=${verified.runtimePid}/${verified.agentPid}`
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
if (!aliveImpl(proof.launcherPid) || !aliveImpl(proof.agentPid)) {
|
|
824
|
+
throw new Error('replacement connected-proof process exited during verification');
|
|
825
|
+
}
|
|
826
|
+
lastVerified = { ...verified, proof, proofSource };
|
|
827
|
+
if (verifiedSince === null) verifiedSince = now();
|
|
828
|
+
if (now() - verifiedSince >= (options.verifyStableMs ?? VERIFY_STABLE_MS)) {
|
|
829
|
+
log(
|
|
830
|
+
`${kind} attempt=${attempt} id=${attemptId} verified `
|
|
831
|
+
+ `launcher=${verified.runtimePid} agent=${verified.agentPid} `
|
|
832
|
+
+ `version=${verified.appVersion}/${proof.agentVersion} proof=${proofSource}`
|
|
833
|
+
);
|
|
834
|
+
writeState(config, kind === 'target' ? 'connected' : 'restored', {
|
|
835
|
+
attemptId,
|
|
836
|
+
attemptKind: kind,
|
|
837
|
+
attemptNumber: attempt,
|
|
838
|
+
restartPid: pid,
|
|
839
|
+
launcherPid: verified.runtimePid,
|
|
840
|
+
agentPid: verified.agentPid,
|
|
841
|
+
productVersion: verified.appVersion,
|
|
842
|
+
agentVersion: proof.agentVersion,
|
|
843
|
+
proofSource,
|
|
844
|
+
restartVerified: kind === 'target',
|
|
845
|
+
restored: kind === 'restore',
|
|
846
|
+
connectedAt: state?.connectedAt || state?.details?.connectedAt || new Date().toISOString(),
|
|
847
|
+
error: kind === 'restore'
|
|
848
|
+
? `Target ${config.targetProductVersion} failed; previous Client ${version} was restored.`
|
|
849
|
+
: ''
|
|
850
|
+
});
|
|
851
|
+
if (!options.readUpdateState) rmSync(proofConfig.statePath, { force: true });
|
|
852
|
+
child.unref?.();
|
|
853
|
+
return { ...verified, agentVersion: proof.agentVersion, attemptId };
|
|
854
|
+
}
|
|
855
|
+
} catch (error) {
|
|
856
|
+
lastError = error;
|
|
857
|
+
const state = readProofState();
|
|
858
|
+
terminalProofFailure = state?.stage === 'connected'
|
|
859
|
+
&& String(state?.operationId || '') === config.operationId
|
|
860
|
+
&& String(state?.attemptId || '') === attemptId
|
|
861
|
+
&& /mismatch|another operation|PID|process exited/i.test(String(error?.message || error));
|
|
862
|
+
verifiedSince = null;
|
|
863
|
+
lastVerified = null;
|
|
864
|
+
}
|
|
865
|
+
if (terminalProofFailure || (pid > 1 && !aliveImpl(pid))) break;
|
|
866
|
+
await sleepImpl(options.verifyPollMs ?? VERIFY_POLL_MS);
|
|
867
|
+
}
|
|
868
|
+
await (options.terminateReplacementTree || terminateReplacementTree)(child);
|
|
869
|
+
if (!options.readUpdateState) rmSync(proofConfig.statePath, { force: true });
|
|
870
|
+
const failure = new Error(
|
|
871
|
+
`${kind} attempt=${attempt} did not become healthy: `
|
|
872
|
+
+ `${lastError?.message || (lastVerified ? 'runtime did not remain stable' : 'verification deadline expired')}`
|
|
873
|
+
);
|
|
874
|
+
writeState(config, `${kind}-attempt-failed`, {
|
|
875
|
+
attemptId,
|
|
876
|
+
attemptKind: kind,
|
|
877
|
+
attemptNumber: attempt,
|
|
878
|
+
restartPid: pid,
|
|
879
|
+
failedAt: new Date().toISOString(),
|
|
880
|
+
restartVerified: false,
|
|
881
|
+
error: failure.message
|
|
882
|
+
});
|
|
883
|
+
throw failure;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
export async function runLegacyClientUpdateSupervisor(options = {}) {
|
|
887
|
+
const env = options.env || process.env;
|
|
888
|
+
const config = readConfig(env);
|
|
889
|
+
const log = createLogger(config, options.writeLog);
|
|
890
|
+
const persistState = options.writeUpdateState || writeUpdateState;
|
|
891
|
+
const productManifest = options.productManifest || readManifest('../package.json');
|
|
892
|
+
const clientManifest = options.clientManifest || readManifest('../client/package.json');
|
|
893
|
+
const sleepImpl = options.sleepImpl || sleep;
|
|
894
|
+
const terminateOld = options.terminateOldRuntime || (value => terminateOldRuntime(value, {
|
|
895
|
+
sleepImpl,
|
|
896
|
+
aliveImpl: options.aliveImpl || isAlive
|
|
897
|
+
}));
|
|
898
|
+
const attemptReplacement = options.attemptReplacement || ((
|
|
899
|
+
version,
|
|
900
|
+
kind,
|
|
901
|
+
attempt,
|
|
902
|
+
preference,
|
|
903
|
+
expectedAgentVersion
|
|
904
|
+
) => (
|
|
905
|
+
launchAndVerifyLegacyClientReplacement(
|
|
906
|
+
config,
|
|
907
|
+
version,
|
|
908
|
+
expectedAgentVersion,
|
|
909
|
+
kind,
|
|
910
|
+
attempt,
|
|
911
|
+
preference,
|
|
912
|
+
{
|
|
913
|
+
...options,
|
|
914
|
+
env,
|
|
915
|
+
log,
|
|
916
|
+
sleepImpl
|
|
917
|
+
}
|
|
918
|
+
)
|
|
919
|
+
));
|
|
920
|
+
|
|
921
|
+
log(`started operation=${config.operationId || 'unknown'} target=${config.targetProductVersion}/${config.targetVersion} current=${config.currentProductVersion || 'unknown'} device=${config.deviceId}`);
|
|
922
|
+
if (!config.operationId
|
|
923
|
+
|| !config.deviceId
|
|
924
|
+
|| !config.targetProductVersion
|
|
925
|
+
|| !config.targetVersion
|
|
926
|
+
|| !config.currentProductVersion
|
|
927
|
+
|| !config.currentAgentVersion
|
|
928
|
+
|| config.launcherPid <= 1
|
|
929
|
+
|| config.agentPid <= 1) {
|
|
930
|
+
throw new Error('legacy update configuration is incomplete');
|
|
931
|
+
}
|
|
932
|
+
if (cleanVersion(productManifest.version) !== config.targetProductVersion) {
|
|
933
|
+
throw new Error(`prepared product mismatch expected=${config.targetProductVersion} actual=${productManifest.version || 'unknown'}`);
|
|
934
|
+
}
|
|
935
|
+
if (cleanVersion(productManifest.livedeskClientVersion) !== config.targetVersion
|
|
936
|
+
|| cleanVersion(clientManifest.version) !== config.targetVersion) {
|
|
937
|
+
throw new Error(`prepared Agent mismatch expected=${config.targetVersion} bundled=${productManifest.livedeskClientVersion || 'unknown'} client=${clientManifest.version || 'unknown'}`);
|
|
938
|
+
}
|
|
939
|
+
const frozen = (options.captureRestartConfiguration || captureRestartConfiguration)(
|
|
940
|
+
config,
|
|
941
|
+
env,
|
|
942
|
+
options.readProcessArguments || readLegacyProcessArguments
|
|
943
|
+
);
|
|
944
|
+
config.restartArgs = [...frozen.restartArgs];
|
|
945
|
+
config.runtimePort = frozen.runtimePort;
|
|
946
|
+
log(`options-frozen count=${config.restartArgs.length} runtimePort=${config.runtimePort} engine=${frozen.engine}`);
|
|
947
|
+
persistState(config, 'preflight-ready', {
|
|
948
|
+
runtimePort: config.runtimePort,
|
|
949
|
+
engine: frozen.engine,
|
|
950
|
+
restartArgCount: config.restartArgs.length,
|
|
951
|
+
restartVerified: false,
|
|
952
|
+
error: ''
|
|
953
|
+
});
|
|
954
|
+
log(`preflight-ready target=${config.targetProductVersion}/${config.targetVersion}`);
|
|
955
|
+
await waitForExit(config.starterPid, 10_000, sleepImpl, options.aliveImpl || isAlive);
|
|
956
|
+
log(`stopping launcher=${config.launcherPid} agent=${config.agentPid}`);
|
|
957
|
+
await terminateOld(config);
|
|
958
|
+
|
|
959
|
+
const targetErrors = [];
|
|
960
|
+
for (let attempt = 1; attempt <= TARGET_ATTEMPT_LIMIT; attempt += 1) {
|
|
961
|
+
try {
|
|
962
|
+
await attemptReplacement(
|
|
963
|
+
config.targetProductVersion,
|
|
964
|
+
'target',
|
|
965
|
+
attempt,
|
|
966
|
+
'--prefer-online',
|
|
967
|
+
config.targetVersion
|
|
968
|
+
);
|
|
969
|
+
log(`completed target=${config.targetProductVersion}/${config.targetVersion}`);
|
|
970
|
+
persistState(config, 'connected', {
|
|
971
|
+
restartVerified: true,
|
|
972
|
+
completedAt: new Date().toISOString(),
|
|
973
|
+
error: ''
|
|
974
|
+
});
|
|
975
|
+
return { outcome: 'updated', attempt };
|
|
976
|
+
} catch (error) {
|
|
977
|
+
targetErrors.push(String(error?.message || error));
|
|
978
|
+
log(`target attempt=${attempt} failed error=${error?.message || error}`);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (!config.currentProductVersion) {
|
|
983
|
+
throw new Error(`target failed and no exact restore version is available: ${targetErrors.join(' | ')}`);
|
|
984
|
+
}
|
|
985
|
+
const restoreErrors = [];
|
|
986
|
+
for (let attempt = 1; attempt <= RESTORE_ATTEMPT_LIMIT; attempt += 1) {
|
|
987
|
+
try {
|
|
988
|
+
await attemptReplacement(
|
|
989
|
+
config.currentProductVersion,
|
|
990
|
+
'restore',
|
|
991
|
+
attempt,
|
|
992
|
+
'--prefer-offline',
|
|
993
|
+
config.currentAgentVersion
|
|
994
|
+
);
|
|
995
|
+
log(`restored version=${config.currentProductVersion}`);
|
|
996
|
+
persistState(config, 'restored', {
|
|
997
|
+
productVersion: config.currentProductVersion,
|
|
998
|
+
agentVersion: config.currentAgentVersion,
|
|
999
|
+
restartVerified: false,
|
|
1000
|
+
restored: true,
|
|
1001
|
+
restoredAt: new Date().toISOString(),
|
|
1002
|
+
error: `Target ${config.targetProductVersion} failed verification: ${targetErrors.join(' | ')}`
|
|
1003
|
+
});
|
|
1004
|
+
return {
|
|
1005
|
+
outcome: 'restored',
|
|
1006
|
+
attempt,
|
|
1007
|
+
error: `Target ${config.targetProductVersion} failed verification: ${targetErrors.join(' | ')}`
|
|
1008
|
+
};
|
|
1009
|
+
} catch (error) {
|
|
1010
|
+
restoreErrors.push(String(error?.message || error));
|
|
1011
|
+
log(`restore attempt=${attempt} failed error=${error?.message || error}`);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
throw new Error(
|
|
1015
|
+
`target failed after ${TARGET_ATTEMPT_LIMIT} attempts and restore failed after ${RESTORE_ATTEMPT_LIMIT} attempts: `
|
|
1016
|
+
+ [...targetErrors, ...restoreErrors].join(' | ')
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
export async function runLegacyClientUpdateSupervisorCli() {
|
|
1021
|
+
try {
|
|
1022
|
+
const result = await runLegacyClientUpdateSupervisor();
|
|
1023
|
+
if (result.outcome === 'restored') process.exitCode = 1;
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
const config = readConfig();
|
|
1026
|
+
const log = createLogger(config);
|
|
1027
|
+
log(`failed error=${error?.stack || error}`);
|
|
1028
|
+
try {
|
|
1029
|
+
writeUpdateState(config, 'failed', {
|
|
1030
|
+
restartVerified: false,
|
|
1031
|
+
failedAt: new Date().toISOString(),
|
|
1032
|
+
error: String(error?.message || error).slice(0, 4000)
|
|
1033
|
+
});
|
|
1034
|
+
} catch {
|
|
1035
|
+
// The persistent log remains the fallback on a read-only state directory.
|
|
1036
|
+
}
|
|
1037
|
+
console.error(error?.message || error);
|
|
1038
|
+
process.exitCode = 1;
|
|
1039
|
+
}
|
|
1040
|
+
}
|