livedesk 0.1.397 → 0.1.399

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 CHANGED
@@ -1146,6 +1146,7 @@ async function runManager(args, resolvedRole = null) {
1146
1146
  LIVEDESK_DEVICE_ID: String(resolvedRole?.deviceId || process.env.LIVEDESK_DEVICE_ID || ''),
1147
1147
  LIVEDESK_DEVICE_NAME: String(resolvedRole?.deviceName || process.env.LIVEDESK_DEVICE_NAME || os.hostname()),
1148
1148
  LIVEDESK_ROLE_CACHE_PATH: join(MANAGER_STATE_DIR, 'device-role.json'),
1149
+ LIVEDESK_AUTH_STATE_PATH: join(MANAGER_STATE_DIR, 'auth.json'),
1149
1150
  LIVEDESK_ROLE_SOURCE: String(resolvedRole?.source || process.env.LIVEDESK_ROLE_SOURCE || '')
1150
1151
  };
1151
1152
  const debugConsole = process.env.LIVEDESK_DEBUG === '1';
@@ -243,8 +243,18 @@ function readConfig(env = process.env) {
243
243
  cleanVersion(env.LIVEDESK_CLIENT_UPDATE_CURRENT_PRODUCT_VERSION)
244
244
  || cleanVersion(env.LIVEDESK_NPM_LAUNCHER_VERSION),
245
245
  currentAgentVersion: cleanVersion(env.LIVEDESK_CLIENT_UPDATE_CURRENT_AGENT_VERSION),
246
- launcherPid: positivePid(env.LIVEDESK_CLIENT_PARENT_PID),
247
- agentPid: positivePid(env.LIVEDESK_CLIENT_UPDATE_AGENT_PID),
246
+ // Dedicated RemoteFast and Node Agent handoffs freeze the owned process
247
+ // tree under UPDATE_WAIT_PID / UPDATE_AGENT_PID. The Hub-owned bridge for
248
+ // older Agents uses the CLIENT_* aliases. Accept both contracts so the
249
+ // exact target package can always prove and stop the same old runtime.
250
+ launcherPid: positivePid(
251
+ env.LIVEDESK_UPDATE_WAIT_PID
252
+ || env.LIVEDESK_CLIENT_PARENT_PID
253
+ ),
254
+ agentPid: positivePid(
255
+ env.LIVEDESK_UPDATE_AGENT_PID
256
+ || env.LIVEDESK_CLIENT_UPDATE_AGENT_PID
257
+ ),
248
258
  starterPid: positivePid(env.LIVEDESK_UPDATE_STARTER_PID),
249
259
  updateDeadlineEpochMs: Number(env.LIVEDESK_CLIENT_UPDATE_DEADLINE_EPOCH_MS),
250
260
  runtimePort: normalizePort(env.LIVEDESK_CLIENT_RUNTIME_PORT || env.LIVEDESK_LOCAL_RUNTIME_PORT),
@@ -1528,15 +1538,20 @@ export async function runLegacyClientUpdateSupervisor(options = {}) {
1528
1538
  ));
1529
1539
 
1530
1540
  log(`started operation=${config.operationId || 'unknown'} target=${config.targetProductVersion}/${config.targetVersion} current=${config.currentProductVersion || 'unknown'} device=${config.deviceId}`);
1531
- if (!config.operationId
1532
- || !config.deviceId
1533
- || !config.targetProductVersion
1534
- || !config.targetVersion
1535
- || !config.currentProductVersion
1536
- || !config.currentAgentVersion
1537
- || config.launcherPid <= 1
1538
- || config.agentPid <= 1) {
1539
- throw new Error('legacy update configuration is incomplete');
1541
+ const missingConfiguration = [
1542
+ ['operationId', config.operationId],
1543
+ ['deviceId', config.deviceId],
1544
+ ['targetProductVersion', config.targetProductVersion],
1545
+ ['targetVersion', config.targetVersion],
1546
+ ['currentProductVersion', config.currentProductVersion],
1547
+ ['currentAgentVersion', config.currentAgentVersion],
1548
+ ['launcherPid', config.launcherPid > 1],
1549
+ ['agentPid', config.agentPid > 1]
1550
+ ].filter(([, value]) => !value).map(([name]) => name);
1551
+ if (missingConfiguration.length > 0) {
1552
+ throw new Error(
1553
+ `legacy update configuration is incomplete: missing ${missingConfiguration.join(', ')}`
1554
+ );
1540
1555
  }
1541
1556
  ensureDeadline('configuration validation');
1542
1557
  if (cleanVersion(productManifest.version) !== config.targetProductVersion) {
@@ -14,6 +14,7 @@ import { createClientRuntimeServer } from '../src/runtime/client-runtime-server.
14
14
  import { installAgentTerminationHandlers } from '../src/runtime/agent-process-lifecycle.js';
15
15
  import { createHubWakeListener } from '../src/runtime/hub-wake-listener.js';
16
16
  import { normalizeRuntimeAuthSession } from '../../runtime-core/src/auth-session.js';
17
+ import { startRoleTransitionSupervisor } from '../../runtime-core/src/role-transition-supervisor.js';
17
18
 
18
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
20
  const require = createRequire(import.meta.url);
@@ -134,27 +135,30 @@ export function buildUnifiedRoleRestartEnvironment(role, baseEnv = process.env,
134
135
  };
135
136
  }
136
137
 
137
- function spawnUnifiedRoleRestart(role) {
138
+ async function spawnUnifiedRoleRestart(role) {
138
139
  if (isTruthy(process.env.LIVEDESK_DESKTOP_HOST)) {
139
140
  console.log(`[LiveDesk] Desktop supervisor will restart the runtime as ${role}.`);
140
141
  return false;
141
142
  }
142
143
  const unifiedEntry = String(process.env.LIVEDESK_UNIFIED_LAUNCHER_ENTRY || '').trim();
143
144
  const restartEnvironment = buildUnifiedRoleRestartEnvironment(role);
144
- const spawnOptions = {
145
+ if (!unifiedEntry || !existsSync(unifiedEntry)) {
146
+ throw new Error(`LiveDesk unified role launcher is unavailable: ${unifiedEntry || 'missing path'}`);
147
+ }
148
+ const handoff = await startRoleTransitionSupervisor({
149
+ role,
150
+ previousPid: process.pid,
151
+ launcherEntry: unifiedEntry,
152
+ stateDir: UNIFIED_CLIENT_STATE_DIR,
153
+ runtimePort: Number(process.env.LIVEDESK_CLIENT_RUNTIME_PORT || DEFAULT_AUTH_CALLBACK_PORT),
154
+ remotePort: resolveHubClientPort(),
145
155
  env: restartEnvironment,
146
- detached: true,
147
- stdio: 'ignore',
148
- windowsHide: true
149
- };
150
- const child = unifiedEntry && existsSync(unifiedEntry)
151
- ? spawn(process.execPath, [unifiedEntry, '--force-role', role, '--no-open'], {
152
- ...spawnOptions
153
- })
154
- : process.platform === 'win32'
155
- ? spawn('npx.cmd', ['-y', '--prefer-online', 'livedesk@latest', '--force-role', role, '--no-open'], spawnOptions)
156
- : spawn('npx', ['-y', '--prefer-online', 'livedesk@latest', '--force-role', role, '--no-open'], spawnOptions);
157
- child.unref();
156
+ cwd: process.cwd()
157
+ });
158
+ console.log(
159
+ `[LiveDesk] Role transition supervisor claimed target=${role} `
160
+ + `pid=${handoff.supervisorPid}. Diagnostics: ${handoff.logPath}`
161
+ );
158
162
  return true;
159
163
  }
160
164
 
@@ -4010,12 +4014,12 @@ export async function runClientRuntime(argv = process.argv.slice(2)) {
4010
4014
  while (true) {
4011
4015
  const prepared = await prepareLoginConnection(parsed, connectionPage);
4012
4016
  connectionPage = prepared.connectionPage || connectionPage;
4013
- const roleRestartBeforeAgent = connectionPage?.getRoleRestartRequest?.() || roleRestartRequest;
4014
- if (roleRestartBeforeAgent?.role) {
4015
- connectionPage?.close?.();
4016
- if (isTruthy(process.env.LIVEDESK_DESKTOP_HOST)) process.exit(43);
4017
- spawnUnifiedRoleRestart(roleRestartBeforeAgent.role);
4018
- process.exit(0);
4017
+ const roleRestartBeforeAgent = connectionPage?.getRoleRestartRequest?.() || roleRestartRequest;
4018
+ if (roleRestartBeforeAgent?.role) {
4019
+ if (isTruthy(process.env.LIVEDESK_DESKTOP_HOST)) process.exit(43);
4020
+ await spawnUnifiedRoleRestart(roleRestartBeforeAgent.role);
4021
+ connectionPage?.close?.();
4022
+ process.exit(0);
4019
4023
  }
4020
4024
  const fastRuntime = getFastRuntime();
4021
4025
  const useFast = shouldTryFast(prepared, fastRuntime);
@@ -4104,12 +4108,12 @@ export async function runClientRuntime(argv = process.argv.slice(2)) {
4104
4108
  });
4105
4109
  });
4106
4110
  }
4107
- const requestedRoleRestart = connectionPage?.getRoleRestartRequest?.();
4108
- if (requestedRoleRestart?.role) {
4109
- connectionPage?.close?.();
4110
- if (isTruthy(process.env.LIVEDESK_DESKTOP_HOST)) process.exit(43);
4111
- spawnUnifiedRoleRestart(requestedRoleRestart.role);
4112
- process.exit(0);
4111
+ const requestedRoleRestart = connectionPage?.getRoleRestartRequest?.();
4112
+ if (requestedRoleRestart?.role) {
4113
+ if (isTruthy(process.env.LIVEDESK_DESKTOP_HOST)) process.exit(43);
4114
+ await spawnUnifiedRoleRestart(requestedRoleRestart.role);
4115
+ connectionPage?.close?.();
4116
+ process.exit(0);
4113
4117
  }
4114
4118
  if (result?.signal) {
4115
4119
  connectionPage?.close?.();
package/hub/src/server.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import express from 'express';
4
4
  import crypto from 'node:crypto';
5
5
  import { createServer } from 'node:http';
6
- import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
6
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
7
7
  import { dirname, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import os from 'node:os';
@@ -106,6 +106,10 @@ const supabasePublishableKey = String(configuredSupabaseKey || PRODUCTION_SUPABA
106
106
  const SUPABASE_AUTH_TIMEOUT_MS = process.env.LIVEDESK_AUTH_TEST_MODE === '1'
107
107
  ? readPositiveIntegerEnv('LIVEDESK_AUTH_TEST_TIMEOUT_MS', AUTH_REQUEST_TIMEOUT_MS)
108
108
  : AUTH_REQUEST_TIMEOUT_MS;
109
+ const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
110
+ const runtimeAuthStatePath = process.env.LIVEDESK_DESKTOP_HOST === '1'
111
+ ? ''
112
+ : String(process.env.LIVEDESK_AUTH_STATE_PATH || '').trim();
109
113
  const testLicensePlan = process.env.LIVEDESK_TEST_MODE === '1'
110
114
  && ['ltd', 'pro'].includes(String(process.env.LIVEDESK_TEST_LICENSE_PLAN || '').toLowerCase())
111
115
  ? String(process.env.LIVEDESK_TEST_LICENSE_PLAN).toLowerCase()
@@ -306,11 +310,82 @@ function normalizeSessionExpiryMs(value) {
306
310
  return numeric < 100_000_000_000 ? Math.round(numeric * 1000) : Math.round(numeric);
307
311
  }
308
312
 
313
+ function readRuntimeAuthState() {
314
+ if (!runtimeAuthStatePath) return {};
315
+ try {
316
+ const state = JSON.parse(readFileSync(runtimeAuthStatePath, 'utf8'));
317
+ return state && typeof state === 'object' ? state : {};
318
+ } catch {
319
+ return {};
320
+ }
321
+ }
322
+
323
+ function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
324
+ try {
325
+ const raw = state?.[CLIENT_AUTH_STORAGE_KEY];
326
+ const session = typeof raw === 'string' ? JSON.parse(raw) : raw;
327
+ return session && typeof session === 'object' ? session : null;
328
+ } catch {
329
+ return null;
330
+ }
331
+ }
332
+
333
+ function writePrivateRuntimeAuthState(state) {
334
+ if (!runtimeAuthStatePath) return false;
335
+ mkdirSync(dirname(runtimeAuthStatePath), { recursive: true });
336
+ const temporaryPath = `${runtimeAuthStatePath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
337
+ try {
338
+ writeFileSync(temporaryPath, JSON.stringify(state, null, 2), 'utf8');
339
+ try { chmodSync(temporaryPath, 0o600); } catch { /* Windows profile ACLs apply instead. */ }
340
+ renameSync(temporaryPath, runtimeAuthStatePath);
341
+ try { chmodSync(runtimeAuthStatePath, 0o600); } catch { /* Windows profile ACLs apply instead. */ }
342
+ return true;
343
+ } catch (error) {
344
+ try { rmSync(temporaryPath, { force: true }); } catch { /* best effort */ }
345
+ throw error;
346
+ }
347
+ }
348
+
349
+ function persistRuntimeSession(session) {
350
+ if (!runtimeAuthStatePath) return false;
351
+ const state = readRuntimeAuthState();
352
+ const previous = readPersistedRuntimeSession(state) || {};
353
+ const incomingUserId = String(session?.user?.id || '').trim();
354
+ const previousUserId = String(previous?.user?.id || '').trim();
355
+ const sameUser = !incomingUserId || !previousUserId || incomingUserId === previousUserId;
356
+ const refreshToken = String(
357
+ session?.refresh_token
358
+ || (sameUser ? previous?.refresh_token : '')
359
+ || ''
360
+ ).trim();
361
+ const normalized = normalizeRuntimeAuthSession({
362
+ ...previous,
363
+ ...session,
364
+ refresh_token: refreshToken,
365
+ user: {
366
+ ...(sameUser && previous?.user && typeof previous.user === 'object' ? previous.user : {}),
367
+ ...(session?.user && typeof session.user === 'object' ? session.user : {})
368
+ }
369
+ }, { requireRefreshToken: true });
370
+ if (!normalized.ok) return false;
371
+ state[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(normalized.session);
372
+ writePrivateRuntimeAuthState(state);
373
+ return true;
374
+ }
375
+
376
+ function clearPersistedRuntimeSession() {
377
+ if (!runtimeAuthStatePath) return false;
378
+ const state = readRuntimeAuthState();
379
+ delete state[CLIENT_AUTH_STORAGE_KEY];
380
+ return writePrivateRuntimeAuthState(state);
381
+ }
382
+
309
383
  function clearRuntimeSession() {
310
384
  runtimeAccessToken = '';
311
385
  runtimeRefreshToken = '';
312
386
  runtimeAccessTokenExpiresAt = 0;
313
387
  runtimeManager.setAuthenticated(false);
388
+ try { clearPersistedRuntimeSession(); } catch { /* runtime auth is already invalid */ }
314
389
  }
315
390
 
316
391
  async function getRuntimeAccessToken() {
@@ -356,6 +431,15 @@ async function getRuntimeAccessToken() {
356
431
  runtimeRefreshToken = String(refreshed?.refresh_token || refreshToken).trim();
357
432
  runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(refreshed?.expires_at)
358
433
  || (Number(refreshed?.expires_in) > 0 ? Date.now() + Number(refreshed.expires_in) * 1000 : 0);
434
+ try {
435
+ persistRuntimeSession({
436
+ access_token: runtimeAccessToken,
437
+ refresh_token: runtimeRefreshToken,
438
+ expires_at: Math.floor(runtimeAccessTokenExpiresAt / 1000)
439
+ });
440
+ } catch (error) {
441
+ console.warn(`[LiveDesk Hub] Refreshed session persistence failed: ${error?.message || error}`);
442
+ }
359
443
  return runtimeAccessToken;
360
444
  }
361
445
 
@@ -2913,13 +2997,19 @@ app.post('/api/auth/session', async (req, res) => {
2913
2997
  runtimeRefreshToken = refreshToken;
2914
2998
  runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
2915
2999
  runtimeManager.setAuthenticated(true, user.id);
3000
+ const persisted = persistRuntimeSession({
3001
+ access_token: accessToken,
3002
+ refresh_token: refreshToken,
3003
+ expires_at: expiresAt,
3004
+ user
3005
+ });
2916
3006
  const hostTarget = runtimeRole === 'hub'
2917
3007
  ? await publishHubHostTargetWithPendingRoleTakeover('session-received')
2918
3008
  : { ok: true, active: false };
2919
3009
  if (runtimeRole === 'hub') {
2920
3010
  startHubHostTargetLeaseRenewal();
2921
3011
  }
2922
- res.json({ ok: true, authenticated: true, userId: user.id, role: runtimeRole, hostTarget });
3012
+ res.json({ ok: true, authenticated: true, persisted, userId: user.id, role: runtimeRole, hostTarget });
2923
3013
  } catch (error) {
2924
3014
  const message = error instanceof Error ? error.message : String(error);
2925
3015
  const status = authVerificationHttpStatus(message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livedesk",
3
- "version": "0.1.397",
3
+ "version": "0.1.399",
4
4
  "livedeskClientVersion": "0.1.173",
5
5
  "buildFlavor": "production",
6
6
  "description": "LiveDesk Hub and client launcher",
@@ -0,0 +1,382 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import {
3
+ appendFileSync,
4
+ closeSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ renameSync,
10
+ rmSync,
11
+ writeFileSync
12
+ } from 'node:fs';
13
+ import net from 'node:net';
14
+ import { dirname, join, resolve } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const ROLE_TRANSITION_SUPERVISOR_FLAG = '--run-livedesk-role-transition-supervisor';
18
+ const SUPERVISOR_CLAIM_TIMEOUT_MS = 5_000;
19
+ const OLD_RUNTIME_EXIT_TIMEOUT_MS = 60_000;
20
+ const REPLACEMENT_HEALTH_TIMEOUT_MS = 25_000;
21
+ const REPLACEMENT_STABILITY_MS = 750;
22
+ const POLL_MS = 100;
23
+
24
+ function wait(milliseconds) {
25
+ return new Promise(resolveWait => setTimeout(resolveWait, milliseconds));
26
+ }
27
+
28
+ function normalizeRole(value) {
29
+ const role = String(value || '').trim().toLowerCase();
30
+ return role === 'hub' || role === 'client' ? role : '';
31
+ }
32
+
33
+ function positiveInteger(value, fallback = 0) {
34
+ const number = Number(value);
35
+ return Number.isInteger(number) && number > 0 ? number : fallback;
36
+ }
37
+
38
+ function processIsAlive(pid) {
39
+ const processId = positiveInteger(pid);
40
+ if (processId <= 1) return false;
41
+ try {
42
+ process.kill(processId, 0);
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ function readJson(path) {
50
+ try {
51
+ return JSON.parse(readFileSync(path, 'utf8'));
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ function writeJsonAtomic(path, value) {
58
+ mkdirSync(dirname(path), { recursive: true });
59
+ const temporaryPath = `${path}.${process.pid}.tmp`;
60
+ try {
61
+ writeFileSync(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
62
+ renameSync(temporaryPath, path);
63
+ } catch (error) {
64
+ try { rmSync(temporaryPath, { force: true }); } catch { /* best effort */ }
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ function appendDiagnostic(path, message) {
70
+ mkdirSync(dirname(path), { recursive: true });
71
+ appendFileSync(path, `[${new Date().toISOString()}] ${String(message || '')}\n`, 'utf8');
72
+ }
73
+
74
+ async function waitForProcessExit(pid, timeoutMs = OLD_RUNTIME_EXIT_TIMEOUT_MS) {
75
+ const deadline = Date.now() + timeoutMs;
76
+ while (Date.now() < deadline) {
77
+ if (!processIsAlive(pid)) return true;
78
+ await wait(POLL_MS);
79
+ }
80
+ return !processIsAlive(pid);
81
+ }
82
+
83
+ async function probeRuntimeStatus(port, role, previousPid) {
84
+ try {
85
+ const response = await fetch(`http://127.0.0.1:${port}/api/runtime/status`, {
86
+ cache: 'no-store',
87
+ signal: AbortSignal.timeout(1_500)
88
+ });
89
+ if (!response.ok) return null;
90
+ const status = await response.json();
91
+ const runtimePid = positiveInteger(status?.runtimePid);
92
+ if (status?.role !== role || runtimePid <= 1 || runtimePid === previousPid) {
93
+ return null;
94
+ }
95
+ if (role === 'hub' && status?.runtimeStarted !== true) {
96
+ return null;
97
+ }
98
+ return { status, runtimePid };
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ function probeTcp(port) {
105
+ return new Promise(resolveProbe => {
106
+ const socket = net.createConnection({ host: '127.0.0.1', port });
107
+ const finish = ok => {
108
+ socket.removeAllListeners();
109
+ socket.destroy();
110
+ resolveProbe(ok);
111
+ };
112
+ const timer = setTimeout(() => finish(false), 1_500);
113
+ timer.unref?.();
114
+ socket.once('connect', () => {
115
+ clearTimeout(timer);
116
+ finish(true);
117
+ });
118
+ socket.once('error', () => {
119
+ clearTimeout(timer);
120
+ finish(false);
121
+ });
122
+ });
123
+ }
124
+
125
+ async function verifyReplacement({ child, role, previousPid, runtimePort, remotePort }) {
126
+ const deadline = Date.now() + REPLACEMENT_HEALTH_TIMEOUT_MS;
127
+ let stableRuntimePid = 0;
128
+ let stableSince = 0;
129
+ while (Date.now() < deadline) {
130
+ if (!processIsAlive(child.pid)) {
131
+ throw new Error(`replacement-launcher-exited:${child.pid || 0}`);
132
+ }
133
+ const probe = await probeRuntimeStatus(runtimePort, role, previousPid);
134
+ const remoteReady = probe && (role !== 'hub' || await probeTcp(remotePort));
135
+ if (probe && remoteReady) {
136
+ if (stableRuntimePid !== probe.runtimePid) {
137
+ stableRuntimePid = probe.runtimePid;
138
+ stableSince = Date.now();
139
+ } else if (Date.now() - stableSince >= REPLACEMENT_STABILITY_MS) {
140
+ return { runtimePid: probe.runtimePid, appVersion: String(probe.status?.appVersion || '') };
141
+ }
142
+ } else {
143
+ stableRuntimePid = 0;
144
+ stableSince = 0;
145
+ }
146
+ await wait(POLL_MS);
147
+ }
148
+ throw new Error(`replacement-health-timeout:${role}`);
149
+ }
150
+
151
+ async function terminateOwnedProcessTree(pid) {
152
+ const processId = positiveInteger(pid);
153
+ if (processId <= 1 || !processIsAlive(processId)) return;
154
+ if (process.platform === 'win32') {
155
+ await new Promise(resolveKill => {
156
+ execFile(
157
+ 'taskkill.exe',
158
+ ['/PID', String(processId), '/T', '/F'],
159
+ { windowsHide: true, timeout: 10_000 },
160
+ () => resolveKill()
161
+ );
162
+ });
163
+ return;
164
+ }
165
+ try { process.kill(-processId, 'SIGTERM'); } catch {
166
+ try { process.kill(processId, 'SIGTERM'); } catch { /* already stopped */ }
167
+ }
168
+ }
169
+
170
+ function supervisorSettings(env = process.env) {
171
+ const launcherEntry = String(env.LIVEDESK_ROLE_TRANSITION_LAUNCHER_ENTRY || '').trim();
172
+ const statePath = String(env.LIVEDESK_ROLE_TRANSITION_STATE_PATH || '').trim();
173
+ const logPath = String(env.LIVEDESK_ROLE_TRANSITION_LOG_PATH || '').trim();
174
+ return {
175
+ transitionId: String(env.LIVEDESK_ROLE_TRANSITION_ID || '').trim(),
176
+ role: normalizeRole(env.LIVEDESK_ROLE_TRANSITION_TARGET),
177
+ previousPid: positiveInteger(env.LIVEDESK_ROLE_TRANSITION_PREVIOUS_PID),
178
+ launcherEntry: launcherEntry ? resolve(launcherEntry) : '',
179
+ statePath: statePath ? resolve(statePath) : '',
180
+ logPath: logPath ? resolve(logPath) : '',
181
+ launchCwd: resolve(String(env.LIVEDESK_ROLE_TRANSITION_CWD || process.cwd()).trim()),
182
+ runtimePort: positiveInteger(env.LIVEDESK_ROLE_TRANSITION_RUNTIME_PORT, 5179),
183
+ remotePort: positiveInteger(env.LIVEDESK_ROLE_TRANSITION_REMOTE_PORT, 5197)
184
+ };
185
+ }
186
+
187
+ async function runRoleTransitionSupervisor(env = process.env) {
188
+ const settings = supervisorSettings(env);
189
+ if (!settings.transitionId || !settings.role || settings.previousPid <= 1) {
190
+ throw new Error('invalid-role-transition-supervisor-settings');
191
+ }
192
+ if (!existsSync(settings.launcherEntry)) {
193
+ throw new Error(`role-transition-launcher-missing:${settings.launcherEntry}`);
194
+ }
195
+
196
+ const writeState = (stage, details = {}) => {
197
+ writeJsonAtomic(settings.statePath, {
198
+ transitionId: settings.transitionId,
199
+ role: settings.role,
200
+ stage,
201
+ supervisorPid: process.pid,
202
+ previousPid: settings.previousPid,
203
+ updatedAt: new Date().toISOString(),
204
+ ...details
205
+ });
206
+ };
207
+
208
+ appendDiagnostic(
209
+ settings.logPath,
210
+ `transition=${settings.transitionId} target=${settings.role} supervisor=${process.pid} previous=${settings.previousPid}`
211
+ );
212
+ writeState('supervisor-ready');
213
+ if (!await waitForProcessExit(settings.previousPid)) {
214
+ throw new Error(`previous-runtime-exit-timeout:${settings.previousPid}`);
215
+ }
216
+ writeState('previous-runtime-exited');
217
+
218
+ let lastError = null;
219
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
220
+ const replacementEnvironment = {
221
+ ...env,
222
+ LIVEDESK_ROLE_TRANSITION: '',
223
+ LIVEDESK_RESTART_WAIT_PID: '',
224
+ LIVEDESK_ROLE_TAKEOVER: settings.role === 'hub' ? '1' : '',
225
+ LIVEDESK_SKIP_BROWSER_OPEN: '1'
226
+ };
227
+ for (const name of Object.keys(replacementEnvironment)) {
228
+ if (name.startsWith('LIVEDESK_ROLE_TRANSITION_')) {
229
+ delete replacementEnvironment[name];
230
+ }
231
+ }
232
+
233
+ let logDescriptor = 0;
234
+ let child = null;
235
+ try {
236
+ logDescriptor = openSync(settings.logPath, 'a');
237
+ child = spawn(
238
+ process.execPath,
239
+ [settings.launcherEntry, '--force-role', settings.role, '--no-open'],
240
+ {
241
+ cwd: settings.launchCwd,
242
+ env: replacementEnvironment,
243
+ detached: true,
244
+ stdio: ['ignore', logDescriptor, logDescriptor],
245
+ windowsHide: true
246
+ }
247
+ );
248
+ await new Promise((resolveSpawn, rejectSpawn) => {
249
+ child.once('spawn', resolveSpawn);
250
+ child.once('error', rejectSpawn);
251
+ });
252
+ } catch (error) {
253
+ lastError = error;
254
+ appendDiagnostic(settings.logPath, `attempt=${attempt} launch-failed error=${error?.message || error}`);
255
+ if (child?.pid && processIsAlive(child.pid)) {
256
+ await terminateOwnedProcessTree(child.pid);
257
+ }
258
+ await wait(350);
259
+ continue;
260
+ } finally {
261
+ if (logDescriptor > 0) closeSync(logDescriptor);
262
+ }
263
+ child.unref();
264
+ writeState('replacement-launched', { attempt, launcherPid: child.pid || 0 });
265
+ appendDiagnostic(settings.logPath, `attempt=${attempt} replacement-launcher=${child.pid || 0}`);
266
+
267
+ try {
268
+ const verified = await verifyReplacement({
269
+ child,
270
+ role: settings.role,
271
+ previousPid: settings.previousPid,
272
+ runtimePort: settings.runtimePort,
273
+ remotePort: settings.remotePort
274
+ });
275
+ writeState('verified', {
276
+ attempt,
277
+ launcherPid: child.pid || 0,
278
+ runtimePid: verified.runtimePid,
279
+ appVersion: verified.appVersion,
280
+ completedAt: new Date().toISOString()
281
+ });
282
+ appendDiagnostic(
283
+ settings.logPath,
284
+ `verified target=${settings.role} launcher=${child.pid || 0} runtime=${verified.runtimePid} version=${verified.appVersion || 'unknown'}`
285
+ );
286
+ return;
287
+ } catch (error) {
288
+ lastError = error;
289
+ appendDiagnostic(settings.logPath, `attempt=${attempt} failed error=${error?.message || error}`);
290
+ if (processIsAlive(child.pid)) {
291
+ await terminateOwnedProcessTree(child.pid);
292
+ }
293
+ await wait(350);
294
+ }
295
+ }
296
+ throw lastError || new Error('role-transition-replacement-failed');
297
+ }
298
+
299
+ export async function startRoleTransitionSupervisor(options = {}) {
300
+ const role = normalizeRole(options.role);
301
+ const previousPid = positiveInteger(options.previousPid, process.pid);
302
+ const launcherEntryValue = String(options.launcherEntry || '').trim();
303
+ const stateDirValue = String(options.stateDir || '').trim();
304
+ const launcherEntry = launcherEntryValue ? resolve(launcherEntryValue) : '';
305
+ const stateDir = stateDirValue ? resolve(stateDirValue) : '';
306
+ if (!role || previousPid <= 1 || !launcherEntry || !existsSync(launcherEntry) || !stateDir) {
307
+ throw new Error('invalid-role-transition-handoff');
308
+ }
309
+
310
+ mkdirSync(stateDir, { recursive: true });
311
+ const statePath = join(stateDir, 'role-transition.json');
312
+ const logPath = join(stateDir, 'role-transition.log');
313
+ const transitionId = `role-${role}-${Date.now()}-${process.pid}`;
314
+ rmSync(statePath, { force: true });
315
+ const child = spawn(
316
+ process.execPath,
317
+ [fileURLToPath(import.meta.url), ROLE_TRANSITION_SUPERVISOR_FLAG],
318
+ {
319
+ cwd: options.cwd || process.cwd(),
320
+ env: {
321
+ ...(options.env || process.env),
322
+ LIVEDESK_ROLE_TRANSITION_ID: transitionId,
323
+ LIVEDESK_ROLE_TRANSITION_TARGET: role,
324
+ LIVEDESK_ROLE_TRANSITION_PREVIOUS_PID: String(previousPid),
325
+ LIVEDESK_ROLE_TRANSITION_LAUNCHER_ENTRY: launcherEntry,
326
+ LIVEDESK_ROLE_TRANSITION_STATE_PATH: statePath,
327
+ LIVEDESK_ROLE_TRANSITION_LOG_PATH: logPath,
328
+ LIVEDESK_ROLE_TRANSITION_CWD: String(options.cwd || process.cwd()),
329
+ LIVEDESK_ROLE_TRANSITION_RUNTIME_PORT: String(positiveInteger(options.runtimePort, 5179)),
330
+ LIVEDESK_ROLE_TRANSITION_REMOTE_PORT: String(positiveInteger(options.remotePort, 5197))
331
+ },
332
+ detached: true,
333
+ stdio: 'ignore',
334
+ windowsHide: true
335
+ }
336
+ );
337
+ await new Promise((resolveSpawn, rejectSpawn) => {
338
+ child.once('spawn', resolveSpawn);
339
+ child.once('error', rejectSpawn);
340
+ });
341
+ child.unref();
342
+
343
+ const deadline = Date.now() + SUPERVISOR_CLAIM_TIMEOUT_MS;
344
+ while (Date.now() < deadline) {
345
+ const state = readJson(statePath);
346
+ if (state?.transitionId === transitionId
347
+ && state?.stage === 'supervisor-ready'
348
+ && positiveInteger(state?.supervisorPid) === child.pid) {
349
+ return { transitionId, supervisorPid: child.pid, statePath, logPath };
350
+ }
351
+ if (!processIsAlive(child.pid)) {
352
+ throw new Error(`role-transition-supervisor-exited:${child.pid || 0}`);
353
+ }
354
+ await wait(50);
355
+ }
356
+ await terminateOwnedProcessTree(child.pid);
357
+ throw new Error(`role-transition-supervisor-claim-timeout:${child.pid || 0}`);
358
+ }
359
+
360
+ const isDirectSupervisorRun = process.argv.includes(ROLE_TRANSITION_SUPERVISOR_FLAG)
361
+ && resolve(String(process.argv[1] || '')) === fileURLToPath(import.meta.url);
362
+ if (isDirectSupervisorRun) {
363
+ runRoleTransitionSupervisor().catch(error => {
364
+ const settings = supervisorSettings();
365
+ try {
366
+ appendDiagnostic(settings.logPath, `failed error=${error?.message || error}`);
367
+ writeJsonAtomic(settings.statePath, {
368
+ transitionId: settings.transitionId,
369
+ role: settings.role,
370
+ stage: 'failed',
371
+ supervisorPid: process.pid,
372
+ previousPid: settings.previousPid,
373
+ error: error?.message || String(error),
374
+ updatedAt: new Date().toISOString(),
375
+ completedAt: new Date().toISOString()
376
+ });
377
+ } catch {
378
+ // The detached supervisor has no console; best-effort diagnostics only.
379
+ }
380
+ process.exitCode = 1;
381
+ });
382
+ }