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.
@@ -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
+ }