@livedesk/client 0.1.171 → 0.1.173

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.
@@ -43,7 +43,84 @@ function normalizeClientUpdateTargetVersion(value) {
43
43
  }
44
44
 
45
45
  function getClientUpdateStatePath() {
46
- return String(process.env.LIVEDESK_CLIENT_UPDATE_STATE_PATH || path.join(os.homedir(), '.livedesk', 'client-update.json'));
46
+ return path.resolve(
47
+ String(process.env.LIVEDESK_CLIENT_UPDATE_STATE_PATH || path.join(os.homedir(), '.livedesk', 'client-update.json'))
48
+ );
49
+ }
50
+
51
+ function getClientUpdateNeutralCwd(operationId) {
52
+ const safeOperationId = String(operationId || '')
53
+ .replace(/[^A-Za-z0-9._-]/g, '_')
54
+ .slice(0, 120) || 'unknown';
55
+ return path.join(os.tmpdir(), 'livedesk-update-cwd', `client-${safeOperationId}`);
56
+ }
57
+
58
+ async function prepareClientUpdateNeutralCwd(operationId) {
59
+ const neutralCwd = path.resolve(getClientUpdateNeutralCwd(operationId));
60
+ await fs.mkdir(neutralCwd, { recursive: true });
61
+ for (let ancestor = neutralCwd; ; ancestor = path.dirname(ancestor)) {
62
+ const shadowPath = [
63
+ path.join(ancestor, 'package.json'),
64
+ path.join(ancestor, 'node_modules', 'livedesk', 'package.json')
65
+ ].find(candidate => existsSync(candidate));
66
+ if (shadowPath) {
67
+ throw new Error(`LiveDesk update neutral working directory is shadowed by ${shadowPath}: ${neutralCwd}`);
68
+ }
69
+ const parent = path.dirname(ancestor);
70
+ if (parent === ancestor) break;
71
+ }
72
+ return neutralCwd;
73
+ }
74
+
75
+ function buildClientUpdateNpxEnvironment(baseEnv, neutralCwd) {
76
+ const env = { ...baseEnv };
77
+ const isolatedNames = new Set([
78
+ 'init_cwd',
79
+ 'npm_config_local_prefix',
80
+ 'npm_config_workspace',
81
+ 'npm_config_workspaces',
82
+ 'npm_config_include_workspace_root',
83
+ 'npm_package_json',
84
+ 'npm_lifecycle_event',
85
+ 'npm_lifecycle_script'
86
+ ]);
87
+ for (const key of Object.keys(env)) {
88
+ if (isolatedNames.has(key.toLowerCase())) delete env[key];
89
+ }
90
+ env.INIT_CWD = neutralCwd;
91
+ env.npm_config_local_prefix = neutralCwd;
92
+ env.npm_config_include_workspace_root = 'false';
93
+ return env;
94
+ }
95
+
96
+ function isClientUpdatePidAlive(value) {
97
+ const pid = Number(value);
98
+ if (!Number.isInteger(pid) || pid <= 1) return false;
99
+ try {
100
+ process.kill(pid, 0);
101
+ return true;
102
+ } catch (error) {
103
+ return error?.code === 'EPERM';
104
+ }
105
+ }
106
+
107
+ function canReclaimClientUpdateOperation(previous) {
108
+ const handoffOwners = [
109
+ previous?.supervisorPid,
110
+ previous?.starterPid
111
+ ].map(Number).filter(pid => Number.isInteger(pid) && pid > 1);
112
+ if (handoffOwners.length > 0) {
113
+ return handoffOwners.every(pid => !isClientUpdatePidAlive(pid));
114
+ }
115
+ if (String(previous?.stage || '') === 'scheduled') {
116
+ const schedulingOwners = [
117
+ previous?.agentPid,
118
+ previous?.launcherPid
119
+ ].map(Number).filter(pid => Number.isInteger(pid) && pid > 1);
120
+ return schedulingOwners.length > 0
121
+ && schedulingOwners.every(pid => !isClientUpdatePidAlive(pid));
122
+ }
123
+ return false;
47
124
  }
48
125
 
49
126
  async function writeClientUpdateState(stage, details = {}) {
@@ -63,14 +140,19 @@ async function writeClientUpdateState(stage, details = {}) {
63
140
  && previous.operationId !== operationId
64
141
  && stage === 'scheduled'
65
142
  && ['connected', 'failed', 'restored'].includes(String(previous.stage || ''));
66
- if (previous?.operationId && previous.operationId !== operationId && !replacesTerminalOperation) return;
67
- if (!replacesTerminalOperation
143
+ const reclaimsDeadOperation = previous?.operationId
144
+ && previous.operationId !== operationId
145
+ && stage === 'scheduled'
146
+ && canReclaimClientUpdateOperation(previous);
147
+ const replacesPreviousOperation = replacesTerminalOperation || reclaimsDeadOperation;
148
+ if (previous?.operationId && previous.operationId !== operationId && !replacesPreviousOperation) return;
149
+ if (!replacesPreviousOperation
68
150
  && previous?.attemptId
69
151
  && attemptId
70
152
  && previous.attemptId !== attemptId) {
71
153
  return;
72
154
  }
73
- if (!replacesTerminalOperation
155
+ if (!replacesPreviousOperation
74
156
  && previous?.stage === 'connected'
75
157
  && previous?.restartVerified === true
76
158
  && stage !== 'connected') {
@@ -79,7 +161,7 @@ async function writeClientUpdateState(stage, details = {}) {
79
161
  const targetProductVersion = String(
80
162
  details.targetProductVersion
81
163
  || process.env.LIVEDESK_CLIENT_UPDATE_TARGET_PRODUCT_VERSION
82
- || (!replacesTerminalOperation ? previous?.targetProductVersion : '')
164
+ || (!replacesPreviousOperation ? previous?.targetProductVersion : '')
83
165
  || ''
84
166
  ).trim();
85
167
  const targetAgentVersion = String(
@@ -87,12 +169,12 @@ async function writeClientUpdateState(stage, details = {}) {
87
169
  || details.targetVersion
88
170
  || process.env.LIVEDESK_CLIENT_UPDATE_TARGET_AGENT_VERSION
89
171
  || process.env.LIVEDESK_CLIENT_UPDATE_TARGET_VERSION
90
- || (!replacesTerminalOperation ? previous?.targetAgentVersion : '')
91
- || (!replacesTerminalOperation ? previous?.targetVersion : '')
172
+ || (!replacesPreviousOperation ? previous?.targetAgentVersion : '')
173
+ || (!replacesPreviousOperation ? previous?.targetVersion : '')
92
174
  || ''
93
175
  ).trim();
94
176
  const state = {
95
- ...(!replacesTerminalOperation && previous && typeof previous === 'object' ? previous : {}),
177
+ ...(!replacesPreviousOperation && previous && typeof previous === 'object' ? previous : {}),
96
178
  operationId,
97
179
  stage,
98
180
  targetVersion: targetAgentVersion,
@@ -110,15 +192,33 @@ async function writeClientUpdateState(stage, details = {}) {
110
192
  const temporaryPath = `${statePath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
111
193
  try {
112
194
  await fs.writeFile(temporaryPath, JSON.stringify(state, null, 2), { encoding: 'utf8', mode: 0o600 });
195
+ let current = null;
196
+ try {
197
+ current = JSON.parse(await fs.readFile(statePath, 'utf8'));
198
+ } catch {
199
+ // A missing first-write state is still owned by this transition.
200
+ }
201
+ if (String(current?.operationId || '') !== String(previous?.operationId || '')
202
+ || String(current?.stage || '') !== String(previous?.stage || '')
203
+ || String(current?.updatedAt || '') !== String(previous?.updatedAt || '')) {
204
+ return;
205
+ }
113
206
  await fs.rename(temporaryPath, statePath);
114
207
  } finally {
115
208
  await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
116
209
  }
117
210
  }
118
211
 
119
- async function waitForClientUpdateShutdownSignal(operationId, timeoutMs = 150_000) {
212
+ async function waitForClientUpdateShutdownSignal(
213
+ operationId,
214
+ updateDeadlineEpochMs,
215
+ timeoutMs = 180_000
216
+ ) {
120
217
  const statePath = getClientUpdateStatePath();
121
- const deadline = Date.now() + timeoutMs;
218
+ const deadline = Math.min(
219
+ Date.now() + timeoutMs,
220
+ Number(updateDeadlineEpochMs) || 0
221
+ );
122
222
  while (Date.now() < deadline) {
123
223
  try {
124
224
  const state = JSON.parse(await fs.readFile(statePath, 'utf8'));
@@ -126,22 +226,32 @@ async function waitForClientUpdateShutdownSignal(operationId, timeoutMs = 150_00
126
226
  await wait(200);
127
227
  continue;
128
228
  }
129
- if (state.stage === 'preflight-ready' || state.stage === 'waiting-for-shutdown') return true;
229
+ if (state.stage === 'preflight-ready' || state.stage === 'waiting-for-shutdown') {
230
+ return { ready: true, error: '' };
231
+ }
130
232
  if (state.stage === 'failed') {
131
- console.error(`LiveDesk client update preparation failed: ${state.error || 'unknown error'}`);
132
- return false;
233
+ const error = String(state.error || 'LiveDesk client update preparation failed.');
234
+ console.error(`LiveDesk client update preparation failed: ${error}`);
235
+ return { ready: false, error };
133
236
  }
134
237
  } catch {
135
238
  // The detached supervisor may still be writing its first stage.
136
239
  }
137
240
  await wait(200);
138
241
  }
242
+ const deadlineExpired = Date.now() >= Number(updateDeadlineEpochMs);
243
+ const error = deadlineExpired
244
+ ? 'The absolute LiveDesk Client update deadline expired before shutdown.'
245
+ : 'Timed out waiting for the LiveDesk update supervisor to prepare the exact package.';
139
246
  await writeClientUpdateState('failed', {
140
247
  operationId,
141
- error: 'Timed out waiting for the LiveDesk update supervisor to prepare the exact package.'
248
+ error,
249
+ cancelRequested: true,
250
+ deadlineExpired,
251
+ failedAt: new Date().toISOString()
142
252
  }).catch(() => undefined);
143
253
  console.error('LiveDesk client update preparation timed out; the current Agent will remain running.');
144
- return false;
254
+ return { ready: false, error };
145
255
  }
146
256
 
147
257
  function markClientUpdateConnected() {
@@ -1451,23 +1561,39 @@ function buildClientUpdateBootstrapScript() {
1451
1561
  'const fs = require("node:fs");',
1452
1562
  'const path = require("node:path");',
1453
1563
  'const operationId = String(process.env.LIVEDESK_CLIENT_UPDATE_OPERATION_ID || "").trim();',
1454
- 'const statePath = String(process.env.LIVEDESK_CLIENT_UPDATE_STATE_PATH || path.join(require("node:os").homedir(), ".livedesk", "client-update.json"));',
1564
+ 'const originalCwd = path.resolve(String(process.env.LIVEDESK_UPDATE_ORIGINAL_CWD || process.cwd()));',
1565
+ 'const statePath = path.resolve(originalCwd, String(process.env.LIVEDESK_CLIENT_UPDATE_STATE_PATH || path.join(require("node:os").homedir(), ".livedesk", "client-update.json")));',
1566
+ 'const neutralCwdValue = String(process.env.LIVEDESK_UPDATE_NEUTRAL_CWD || "").trim();',
1567
+ 'const neutralCwd = neutralCwdValue ? path.resolve(neutralCwdValue) : "";',
1455
1568
  'const cleanVersion = value => String(value || "").trim().replace(/^v/i, "");',
1456
1569
  'const targetProductVersion = cleanVersion(process.env.LIVEDESK_CLIENT_UPDATE_TARGET_PRODUCT_VERSION || process.env.LIVEDESK_CLIENT_UPDATE_TARGET_VERSION);',
1570
+ 'const updateDeadlineEpochMs = Number(process.env.LIVEDESK_CLIENT_UPDATE_DEADLINE_EPOCH_MS || 0);',
1571
+ 'const deadlineExpired = () => !Number.isSafeInteger(updateDeadlineEpochMs) || Date.now() >= updateDeadlineEpochMs;',
1457
1572
  'const versionPattern = /^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$/;',
1458
1573
  'const readState = () => { try { return JSON.parse(fs.readFileSync(statePath, "utf8")); } catch { return {}; } };',
1459
- 'const writeFailure = error => { try { fs.mkdirSync(path.dirname(statePath), { recursive: true }); const previous = readState(); if (previous.operationId && previous.operationId !== operationId) return; const now = new Date().toISOString(); const next = { ...previous, operationId, stage: "failed", targetProductVersion, restartVerified: false, error: String(error?.message || error).slice(0, 4000), failedAt: now, updatedAt: now }; const temporary = statePath + "." + process.pid + ".handoff.tmp"; fs.writeFileSync(temporary, JSON.stringify(next, null, 2), { encoding: "utf8", mode: 0o600 }); fs.renameSync(temporary, statePath); } catch {} process.stderr.write("LiveDesk update handoff failed: " + (error?.message || error) + "\\n"); process.exitCode = 1; };',
1460
- 'const monitorPreflight = child => { let settled = false; const finish = (error, preserveFailure = false) => { if (settled) return; settled = true; clearInterval(poll); clearTimeout(timeout); child.unref(); if (preserveFailure) process.exitCode = 1; else if (error) writeFailure(error); }; const inspect = () => { const state = readState(); if (state.operationId !== operationId) return; if (state.stage === "preflight-ready" || state.stage === "waiting-for-shutdown") finish(); else if (state.stage === "failed") finish(null, true); }; const poll = setInterval(inspect, 100); const timeout = setTimeout(() => { try { child.kill(); } catch {} finish(new Error("Timed out waiting for exact-package update preflight.")); }, Math.max(1000, Number(process.env.LIVEDESK_CLIENT_UPDATE_HANDOFF_TIMEOUT_MS || 140000))); child.once("exit", (code, signal) => { inspect(); if (!settled) finish(new Error("Exact-package update supervisor exited before preflight (code=" + (code ?? "none") + ", signal=" + (signal || "none") + ").")); }); inspect(); };',
1461
- 'if (!operationId || !versionPattern.test(targetProductVersion)) { writeFailure(new Error("Invalid LiveDesk exact-package update handoff.")); } else {',
1462
- 'const env = { ...process.env, LIVEDESK_UPDATE_STARTER_PID: String(process.pid) };',
1574
+ 'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));',
1575
+ 'const isAlive = pid => { try { process.kill(pid, 0); return true; } catch (error) { return error?.code === "EPERM"; } };',
1576
+ 'const isGroupAlive = pid => { try { process.kill(-pid, 0); return true; } catch (error) { return error?.code === "EPERM"; } };',
1577
+ 'const stateLockPath = statePath + ".lock"; const lockWaitBuffer = new Int32Array(new SharedArrayBuffer(4));',
1578
+ 'const waitForStateLock = ms => Atomics.wait(lockWaitBuffer, 0, 0, Math.max(1, ms));',
1579
+ 'const removeAbandonedStateLock = () => { let owner = null; try { owner = JSON.parse(fs.readFileSync(stateLockPath, "utf8")); } catch {} const ownerPid = Number(owner?.pid || 0); if (Number.isInteger(ownerPid) && ownerPid > 1) { if (isAlive(ownerPid)) return false; } else { try { if (Date.now() - fs.statSync(stateLockPath).mtimeMs < 5000) return false; } catch { return true; } } try { fs.rmSync(stateLockPath); return true; } catch { return false; } };',
1580
+ 'const withStateLock = callback => { fs.mkdirSync(path.dirname(stateLockPath), { recursive: true }); const deadline = Date.now() + 10000; let descriptor = null; let token = ""; while (descriptor === null) { try { descriptor = fs.openSync(stateLockPath, "wx", 0o600); token = process.pid + "-" + Date.now() + "-" + Math.random().toString(16).slice(2); fs.writeFileSync(descriptor, JSON.stringify({ pid: process.pid, token, acquiredAt: new Date().toISOString() }), "utf8"); } catch (error) { if (descriptor !== null) { try { fs.closeSync(descriptor); } catch {} descriptor = null; try { fs.rmSync(stateLockPath, { force: true }); } catch {} } if (error?.code !== "EEXIST") throw error; if (removeAbandonedStateLock()) continue; if (Date.now() >= deadline) throw new Error("Timed out waiting for the LiveDesk update state lock: " + stateLockPath); waitForStateLock(Math.min(25, Math.max(1, deadline - Date.now()))); } } try { return callback(); } finally { try { fs.closeSync(descriptor); } catch {} try { const owner = JSON.parse(fs.readFileSync(stateLockPath, "utf8")); if (owner?.token === token && Number(owner?.pid) === process.pid) fs.rmSync(stateLockPath, { force: true }); } catch {} } };',
1581
+ 'const writeFailure = (error, cancelRequested = false) => { try { withStateLock(() => { const previous = readState(); if (previous.operationId && previous.operationId !== operationId) return; if (previous.stage === "preflight-ready" || previous.stage === "waiting-for-shutdown" || previous.stage === "connected" || previous.stage === "restored") return; const now = new Date().toISOString(); const next = { ...previous, operationId, stage: "failed", targetProductVersion, restartVerified: false, cancelRequested: cancelRequested || previous.cancelRequested === true, error: String(error?.message || error).slice(0, 4000), failedAt: now, updatedAt: now }; const temporary = statePath + "." + process.pid + ".handoff.tmp"; fs.writeFileSync(temporary, JSON.stringify(next, null, 2), { encoding: "utf8", mode: 0o600 }); fs.renameSync(temporary, statePath); }); } catch {} process.stderr.write("LiveDesk update handoff failed: " + (error?.message || error) + "\\n"); process.exitCode = 1; };',
1582
+ 'const writeCancellation = error => withStateLock(() => { const previous = readState(); if (previous.operationId && previous.operationId !== operationId) return "superseded"; if (previous.operationId !== operationId) return false; if (previous.stage === "preflight-ready" || previous.stage === "waiting-for-shutdown") return "ready"; if (previous.stage === "failed") return previous.cancelRequested === true ? "cancelled" : "failed"; const now = new Date().toISOString(); const next = { ...previous, operationId, stage: "failed", targetProductVersion, restartVerified: false, cancelRequested: true, error: String(error?.message || error).slice(0, 4000), cancelledAt: now, failedAt: now, updatedAt: now }; const temporary = statePath + "." + process.pid + ".cancel.tmp"; fs.writeFileSync(temporary, JSON.stringify(next, null, 2), { encoding: "utf8", mode: 0o600 }); fs.renameSync(temporary, statePath); return "cancelled"; });',
1583
+ 'const terminateTree = async child => { const pid = Number(child?.pid || 0); if (pid <= 1) return; if (process.platform === "win32") { await new Promise(resolve => { const killer = spawn("taskkill.exe", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }); let done = false; const finish = () => { if (done) return; done = true; clearTimeout(timer); resolve(); }; const timer = setTimeout(() => { try { killer.kill("SIGKILL"); } catch {} finish(); }, 10000); killer.once("error", finish); killer.once("exit", finish); }); if (isAlive(pid)) { try { child.kill("SIGKILL"); } catch {} } return; } try { process.kill(-pid, "SIGTERM"); } catch { try { child.kill("SIGTERM"); } catch {} } const gracefulDeadline = Date.now() + 2000; while (Date.now() < gracefulDeadline && (isGroupAlive(pid) || isAlive(pid))) await sleep(100); if (isGroupAlive(pid) || isAlive(pid)) { try { process.kill(-pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch {} } } };',
1584
+ 'const writeHandoffStarted = () => withStateLock(() => { if (deadlineExpired()) throw new Error("The absolute LiveDesk Client update deadline expired before handoff."); const previous = readState(); if (previous.operationId !== operationId) throw new Error("LiveDesk update handoff was superseded before exact-package launch."); const now = new Date().toISOString(); const next = { ...previous, operationId, stage: "handoff-started", starterPid: process.pid, updateDeadlineEpochMs, restartVerified: false, error: "", updatedAt: now }; const temporary = statePath + "." + process.pid + ".handoff-started.tmp"; fs.writeFileSync(temporary, JSON.stringify(next, null, 2), { encoding: "utf8", mode: 0o600 }); fs.renameSync(temporary, statePath); });',
1585
+ 'const monitorPreflight = child => { let settled = false; const finish = (error, preserveFailure = false) => { if (settled) return; settled = true; clearInterval(poll); clearTimeout(timeout); child.unref(); if (preserveFailure) process.exitCode = 1; else if (error) writeFailure(error); }; const inspect = () => { const state = readState(); if (state.operationId !== operationId) return; if (state.stage === "preflight-ready" || state.stage === "waiting-for-shutdown") finish(); else if (state.stage === "failed") finish(null, true); }; const cancelTimedOutHandoff = async () => { const error = new Error(deadlineExpired() ? "The absolute LiveDesk Client update deadline expired before exact-package preflight." : "Timed out waiting for exact-package update preflight."); while (!settled) { inspect(); if (settled) return; let outcome; try { outcome = writeCancellation(error); } catch (lockError) { finish(lockError); return; } if (outcome === "ready" || outcome === "failed") { inspect(); if (settled) return; } else if (outcome === "cancelled" || outcome === "superseded") { await terminateTree(child); finish(null, true); return; } await sleep(25); } }; const poll = setInterval(inspect, 100); const timeoutMs = Math.max(1, Math.min(Math.max(1000, Number(process.env.LIVEDESK_CLIENT_UPDATE_HANDOFF_TIMEOUT_MS || 140000)), updateDeadlineEpochMs - Date.now())); const timeout = setTimeout(() => { void cancelTimedOutHandoff(); }, timeoutMs); child.once("exit", (code, signal) => { inspect(); if (!settled) finish(new Error("Exact-package update supervisor exited before preflight (code=" + (code ?? "none") + ", signal=" + (signal || "none") + ").")); }); inspect(); };',
1586
+ 'const prepareNeutralCwd = () => { if (!neutralCwd) throw new Error("LiveDesk update neutral working directory is unavailable"); fs.mkdirSync(neutralCwd, { recursive: true }); for (let ancestor = neutralCwd; ; ancestor = path.dirname(ancestor)) { const shadow = [path.join(ancestor, "package.json"), path.join(ancestor, "node_modules", "livedesk", "package.json")].find(fs.existsSync); if (shadow) throw new Error("LiveDesk update neutral working directory is shadowed by " + shadow + ": " + neutralCwd); const parent = path.dirname(ancestor); if (parent === ancestor) break; } };',
1587
+ 'if (!operationId || !versionPattern.test(targetProductVersion) || deadlineExpired()) { writeFailure(new Error("Invalid or expired LiveDesk exact-package update handoff."), true); } else { try { prepareNeutralCwd(); writeHandoffStarted();',
1588
+ 'const env = { ...process.env, LIVEDESK_UPDATE_STARTER_PID: String(process.pid) }; const isolated = new Set(["init_cwd", "npm_config_local_prefix", "npm_config_workspace", "npm_config_workspaces", "npm_config_include_workspace_root", "npm_package_json", "npm_lifecycle_event", "npm_lifecycle_script"]); for (const key of Object.keys(env)) if (isolated.has(key.toLowerCase())) delete env[key]; env.INIT_CWD = neutralCwd; env.npm_config_local_prefix = neutralCwd; env.npm_config_include_workspace_root = "false";',
1463
1589
  'const nodeDir = path.dirname(process.execPath);',
1464
1590
  'const npxCli = [env.LIVEDESK_NPX_CLI_PATH, env.npm_execpath ? path.join(path.dirname(env.npm_execpath), "npx-cli.js") : "", env.LIVEDESK_NPX_EXECUTABLE ? path.join(path.dirname(env.LIVEDESK_NPX_EXECUTABLE), "node_modules", "npm", "bin", "npx-cli.js") : "", path.join(nodeDir, "node_modules", "npm", "bin", "npx-cli.js")].find(value => value && fs.existsSync(value));',
1465
1591
  'const npx = env.LIVEDESK_NPX_EXECUTABLE || (process.platform === "win32" ? "npx.cmd" : "npx");',
1466
1592
  'const args = ["-y", "--prefer-online", "livedesk@" + targetProductVersion, "--internal-legacy-client-update"];',
1467
1593
  'const quoteCmd = value => "\\"" + String(value).replaceAll("%", "%%").replaceAll("\\"", "\\"\\"") + "\\"";',
1468
1594
  'const invocation = npxCli ? { command: process.execPath, args: [npxCli, ...args] } : process.platform === "win32" ? { command: env.ComSpec || "cmd.exe", args: ["/d", "/s", "/c", "call " + quoteCmd(npx) + " " + args.map(quoteCmd).join(" ")] } : { command: npx, args };',
1469
- 'if (!npxCli && path.isAbsolute(npx) && !fs.existsSync(npx)) { writeFailure(new Error("LiveDesk npx executable was not found: " + npx)); } else { try { const child = spawn(invocation.command, invocation.args, { env, detached: true, stdio: "ignore", windowsHide: true }); child.once("error", writeFailure); child.once("spawn", () => monitorPreflight(child)); } catch (error) { writeFailure(error); } }',
1470
- '}',
1595
+ 'if (!npxCli && path.isAbsolute(npx) && !fs.existsSync(npx)) { writeFailure(new Error("LiveDesk npx executable was not found: " + npx)); } else { try { const child = spawn(invocation.command, invocation.args, { cwd: neutralCwd, env, detached: true, stdio: "ignore", windowsHide: true }); child.once("error", writeFailure); child.once("spawn", () => monitorPreflight(child)); } catch (error) { writeFailure(error); } }',
1596
+ '} catch (error) { writeFailure(error, deadlineExpired()); } }',
1471
1597
  ''
1472
1598
  ].join('\n');
1473
1599
  }
@@ -1477,6 +1603,7 @@ async function scheduleClientUpdate(options, payload = {}) {
1477
1603
  const statePath = getClientUpdateStatePath();
1478
1604
  let targetProductVersion = '';
1479
1605
  let targetAgentVersion = '';
1606
+ let updateDeadlineEpochMs = 0;
1480
1607
  try {
1481
1608
  targetProductVersion = normalizeClientUpdateTargetVersion(
1482
1609
  payload.targetProductVersion
@@ -1496,11 +1623,22 @@ async function scheduleClientUpdate(options, payload = {}) {
1496
1623
  || targetProductVersion
1497
1624
  || ''
1498
1625
  );
1626
+ const updateTimeoutMs = Number(payload.updateTimeoutMs);
1627
+ if (!Number.isSafeInteger(updateTimeoutMs)
1628
+ || updateTimeoutMs < 1_000
1629
+ || updateTimeoutMs > 90 * 60_000) {
1630
+ throw new Error('LiveDesk Client update requires a bounded deadline budget.');
1631
+ }
1632
+ updateDeadlineEpochMs = Date.now() + updateTimeoutMs;
1633
+ const originalCwd = path.resolve(process.cwd());
1634
+ const neutralCwd = await prepareClientUpdateNeutralCwd(operationId);
1499
1635
  await writeClientUpdateState('scheduled', {
1500
1636
  operationId,
1501
1637
  targetVersion: targetAgentVersion,
1502
1638
  targetProductVersion,
1503
1639
  targetAgentVersion,
1640
+ updateTimeoutMs,
1641
+ updateDeadlineEpochMs,
1504
1642
  requestedAt: new Date().toISOString(),
1505
1643
  restartVerified: false,
1506
1644
  error: ''
@@ -1520,29 +1658,41 @@ async function scheduleClientUpdate(options, payload = {}) {
1520
1658
  if (!command) {
1521
1659
  throw new Error('LiveDesk npx executable was not found.');
1522
1660
  }
1661
+ const frozenCommand = path.isAbsolute(command) || !/[\\/]/.test(command)
1662
+ ? command
1663
+ : path.resolve(originalCwd, command);
1664
+ const configuredLocalLauncher = String(
1665
+ process.env.LIVEDESK_UNIFIED_LAUNCHER_ENTRY
1666
+ || process.env.LIVEDESK_CLIENT_UPDATE_LOCAL_LAUNCHER
1667
+ || ''
1668
+ ).trim();
1669
+ const localLauncher = configuredLocalLauncher
1670
+ ? path.resolve(originalCwd, configuredLocalLauncher)
1671
+ : '';
1672
+ const updateEnvironment = buildClientUpdateNpxEnvironment({
1673
+ ...process.env,
1674
+ LIVEDESK_NODE_EXECUTABLE: process.execPath,
1675
+ LIVEDESK_NPX_EXECUTABLE: frozenCommand,
1676
+ LIVEDESK_UPDATE_WAIT_PID: String(parentPid),
1677
+ LIVEDESK_UPDATE_AGENT_PID: String(process.pid),
1678
+ LIVEDESK_SKIP_BROWSER_OPEN: '1',
1679
+ LIVEDESK_CLIENT_UPDATE_TARGET_VERSION: targetAgentVersion,
1680
+ LIVEDESK_CLIENT_UPDATE_TARGET_PRODUCT_VERSION: targetProductVersion,
1681
+ LIVEDESK_CLIENT_UPDATE_TARGET_AGENT_VERSION: targetAgentVersion,
1682
+ LIVEDESK_CLIENT_UPDATE_CURRENT_PRODUCT_VERSION: PRODUCT_VERSION,
1683
+ LIVEDESK_CLIENT_UPDATE_CURRENT_AGENT_VERSION: AGENT_VERSION,
1684
+ LIVEDESK_CLIENT_UPDATE_LOCAL_LAUNCHER: localLauncher,
1685
+ LIVEDESK_CLIENT_UPDATE_OPERATION_ID: operationId,
1686
+ LIVEDESK_CLIENT_UPDATE_DEADLINE_EPOCH_MS: String(updateDeadlineEpochMs),
1687
+ LIVEDESK_CLIENT_UPDATE_STATE_PATH: statePath,
1688
+ LIVEDESK_UPDATE_NEUTRAL_CWD: neutralCwd,
1689
+ LIVEDESK_UPDATE_ORIGINAL_CWD: originalCwd,
1690
+ LIVEDESK_CLIENT_UPDATE_CWD: originalCwd
1691
+ }, neutralCwd);
1523
1692
  return await new Promise((resolve, reject) => {
1524
1693
  const child = spawn(process.execPath, ['-e', buildClientUpdateBootstrapScript()], {
1525
- env: {
1526
- ...process.env,
1527
- LIVEDESK_NODE_EXECUTABLE: process.execPath,
1528
- LIVEDESK_NPX_EXECUTABLE: command,
1529
- LIVEDESK_UPDATE_WAIT_PID: String(parentPid),
1530
- LIVEDESK_UPDATE_AGENT_PID: String(process.pid),
1531
- LIVEDESK_SKIP_BROWSER_OPEN: '1',
1532
- LIVEDESK_CLIENT_UPDATE_TARGET_VERSION: targetAgentVersion,
1533
- LIVEDESK_CLIENT_UPDATE_TARGET_PRODUCT_VERSION: targetProductVersion,
1534
- LIVEDESK_CLIENT_UPDATE_TARGET_AGENT_VERSION: targetAgentVersion,
1535
- LIVEDESK_CLIENT_UPDATE_CURRENT_PRODUCT_VERSION: PRODUCT_VERSION,
1536
- LIVEDESK_CLIENT_UPDATE_CURRENT_AGENT_VERSION: AGENT_VERSION,
1537
- LIVEDESK_CLIENT_UPDATE_LOCAL_LAUNCHER: String(
1538
- process.env.LIVEDESK_UNIFIED_LAUNCHER_ENTRY
1539
- || process.env.LIVEDESK_CLIENT_UPDATE_LOCAL_LAUNCHER
1540
- || ''
1541
- ),
1542
- LIVEDESK_CLIENT_UPDATE_OPERATION_ID: operationId,
1543
- LIVEDESK_CLIENT_UPDATE_STATE_PATH: statePath,
1544
- LIVEDESK_CLIENT_UPDATE_CWD: process.cwd()
1545
- },
1694
+ cwd: neutralCwd,
1695
+ env: updateEnvironment,
1546
1696
  detached: true,
1547
1697
  stdio: 'ignore',
1548
1698
  windowsHide: true
@@ -1557,6 +1707,7 @@ async function scheduleClientUpdate(options, payload = {}) {
1557
1707
  targetProductVersion,
1558
1708
  targetAgentVersion,
1559
1709
  operationId,
1710
+ updateDeadlineEpochMs,
1560
1711
  statePath,
1561
1712
  parentPid,
1562
1713
  restartAfterMs: 0
@@ -1610,8 +1761,27 @@ async function handleRemoteCommand(socket, options, message, nextFrameSeq, activ
1610
1761
  commandId: message.commandId,
1611
1762
  result
1612
1763
  });
1613
- if (await waitForClientUpdateShutdownSignal(result.operationId)) {
1614
- process.exit(EXIT_CLIENT_UPDATE);
1764
+ const shutdownSignal = await waitForClientUpdateShutdownSignal(
1765
+ result.operationId,
1766
+ result.updateDeadlineEpochMs
1767
+ );
1768
+ if (shutdownSignal.ready) {
1769
+ // The exact package supervisor is the sole owner of old-runtime
1770
+ // termination, so a deadline can never race a voluntary exit.
1771
+ return;
1772
+ } else {
1773
+ writeJsonLine(socket, {
1774
+ type: 'command.result',
1775
+ commandId: message.commandId,
1776
+ error: shutdownSignal.error,
1777
+ result: {
1778
+ ok: false,
1779
+ status: 'failed',
1780
+ error: shutdownSignal.error,
1781
+ operationId: result.operationId,
1782
+ completedAt: new Date().toISOString()
1783
+ }
1784
+ });
1615
1785
  }
1616
1786
  } catch (error) {
1617
1787
  writeJsonLine(socket, {
@@ -2888,15 +2888,18 @@ async function startConnectionChoiceServer(supabase, options = {}) {
2888
2888
 
2889
2889
  async function chooseClientConnection(supabase, options = {}) {
2890
2890
  if (isTruthy(process.env.LIVEDESK_UNIFIED_RUNTIME)) {
2891
- const connectionPage = createClientRuntimeServer({
2892
- host: process.env.LIVEDESK_CLIENT_RUNTIME_HOST || '127.0.0.1',
2893
- port: options.authPort || DEFAULT_AUTH_CALLBACK_PORT,
2894
- webDist: process.env.LIVEDESK_WEB_DIST || '',
2891
+ const connectionPage = createClientRuntimeServer({
2892
+ host: process.env.LIVEDESK_CLIENT_RUNTIME_HOST || '127.0.0.1',
2893
+ port: options.authPort || DEFAULT_AUTH_CALLBACK_PORT,
2894
+ webDist: process.env.LIVEDESK_WEB_DIST || '',
2895
2895
  appVersion: process.env.LIVEDESK_NPM_LAUNCHER_VERSION || readPackageVersion(),
2896
- deviceId: options.deviceId,
2897
- engine: options.engine,
2896
+ deviceId: options.deviceId,
2897
+ engine: options.engine,
2898
2898
  savedSession: options.savedSession,
2899
+ initialChoice: options.initialChoice,
2900
+ initialChoiceMessage: options.initialChoiceMessage,
2899
2901
  beginGoogleSignIn: async redirectTo => {
2902
+ if (!supabase?.auth) throw new Error('supabase-session-required');
2900
2903
  const { data, error } = await supabase.auth.signInWithOAuth({
2901
2904
  provider: 'google',
2902
2905
  options: {
@@ -2910,15 +2913,22 @@ async function chooseClientConnection(supabase, options = {}) {
2910
2913
  return { url: data.url };
2911
2914
  },
2912
2915
  exchangeGoogleCode: async code => {
2916
+ if (!supabase?.auth) throw new Error('supabase-session-required');
2913
2917
  const { data, error } = await supabase.auth.exchangeCodeForSession(code);
2914
2918
  if (error) throw error;
2915
2919
  if (!data?.session) throw new Error('google-session-missing');
2916
2920
  if (!writeSavedSessionToFile(data.session)) throw new Error('refresh-token-required');
2917
2921
  return data.session;
2918
2922
  },
2919
- resolvePin: pin => resolveManagerFromPin(supabase, pin),
2920
- changeRole: async (role, snapshot) => {
2921
- const session = await refreshSessionIfNeeded(supabase);
2923
+ resolvePin: pin => {
2924
+ if (!supabase) throw new Error('supabase-session-required');
2925
+ return resolveManagerFromPin(supabase, pin);
2926
+ },
2927
+ changeRole: async (role, snapshot) => {
2928
+ if (!supabase) {
2929
+ return { ok: false, error: 'supabase-session-required' };
2930
+ }
2931
+ const session = await refreshSessionIfNeeded(supabase);
2922
2932
  if (!session?.access_token) {
2923
2933
  return { ok: false, error: 'supabase-session-required' };
2924
2934
  }
@@ -3255,7 +3265,7 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
3255
3265
  let connectionPage = null;
3256
3266
  let discoverySource = '';
3257
3267
 
3258
- if (shouldLogin) {
3268
+ if (shouldLogin) {
3259
3269
  const supabase = await createSupabaseClient();
3260
3270
  const startupArgs = buildStartupClientArgs(parsed);
3261
3271
  let savedSession = null;
@@ -3362,10 +3372,38 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null) {
3362
3372
  message: `Connected to LiveDesk Hub at ${manager}.`
3363
3373
  });
3364
3374
  }
3365
- console.log(`Found LiveDesk Hub at ${manager}.`);
3366
- }
3367
-
3368
- const slot = normalizeSlotNumber(parsed.slot);
3375
+ console.log(`Found LiveDesk Hub at ${manager}.`);
3376
+ }
3377
+ // Exact-package updates preserve the already paired Hub endpoint and pass
3378
+ // --no-login so the replacement cannot block on browser auth. The unified
3379
+ // launcher must still own its local runtime/status API; the package
3380
+ // supervisor uses that API to prove the new launcher and Agent are stable
3381
+ // before it commits the update.
3382
+ if (!shouldLogin
3383
+ && !existingConnectionPage
3384
+ && isTruthy(process.env.LIVEDESK_UNIFIED_RUNTIME)
3385
+ && manager
3386
+ && pair) {
3387
+ const preservedSession = readSavedSessionFromFile();
3388
+ const explicitConnection = await chooseClientConnection(null, {
3389
+ authPort: parsed.authPort,
3390
+ deviceId: parsed.deviceId,
3391
+ engine: parsed.engine,
3392
+ savedSession: preservedSession,
3393
+ openBrowser: false,
3394
+ initialChoice: {
3395
+ type: 'explicit',
3396
+ manager,
3397
+ pair,
3398
+ endpointCandidates: [manager],
3399
+ ...(preservedSession?.access_token ? { session: preservedSession } : {})
3400
+ },
3401
+ initialChoiceMessage: `Using the existing LiveDesk Hub pairing at ${manager}.`
3402
+ });
3403
+ connectionPage = explicitConnection.connectionPage || null;
3404
+ }
3405
+
3406
+ const slot = normalizeSlotNumber(parsed.slot);
3369
3407
  if (manager) {
3370
3408
  forwarded = upsertForwardedOption(forwarded, '--manager', manager);
3371
3409
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/client",
3
- "version": "0.1.171",
3
+ "version": "0.1.173",
4
4
  "description": "LiveDesk local remote client",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,10 +39,10 @@
39
39
  "ws": "^8.18.3"
40
40
  },
41
41
  "optionalDependencies": {
42
- "@livedesk/fast-linux-x64": "0.1.377",
43
- "@livedesk/fast-osx-arm64": "0.1.377",
44
- "@livedesk/fast-osx-x64": "0.1.377",
45
- "@livedesk/fast-win-x64": "0.1.377"
42
+ "@livedesk/fast-linux-x64": "0.1.379",
43
+ "@livedesk/fast-osx-arm64": "0.1.379",
44
+ "@livedesk/fast-osx-x64": "0.1.379",
45
+ "@livedesk/fast-win-x64": "0.1.379"
46
46
  },
47
47
  "publishConfig": {
48
48
  "access": "public"
@@ -514,9 +514,16 @@ function sendText(res, status, body, type = 'text/plain; charset=utf-8') {
514
514
  res.end(body);
515
515
  }
516
516
 
517
- export function createClientRuntimeServer(options = {}) {
518
- const host = normalizeString(options.host, 80) || DEFAULT_HOST;
517
+ export function createClientRuntimeServer(options = {}) {
518
+ const host = normalizeString(options.host, 80) || DEFAULT_HOST;
519
519
  const port = normalizePort(options.port, DEFAULT_PORT);
520
+ const initialChoice = options.initialChoice && typeof options.initialChoice === 'object'
521
+ ? options.initialChoice
522
+ : null;
523
+ const initialChoiceMessage = normalizeString(
524
+ options.initialChoiceMessage,
525
+ 1000
526
+ ) || 'Existing LiveDesk credentials accepted. Starting the Client.';
520
527
  const trustedWebOrigins = createTrustedWebOrigins(port);
521
528
  const webDist = resolve(String(options.webDist || process.env.LIVEDESK_WEB_DIST || '').trim() || join(process.cwd(), 'apps', 'web', 'dist'));
522
529
  const deviceId = normalizeString(options.deviceId || process.env.LIVEDESK_DEVICE_ID, 160);
@@ -1030,23 +1037,29 @@ export function createClientRuntimeServer(options = {}) {
1030
1037
  else res.end();
1031
1038
  });
1032
1039
  });
1033
- server.once('error', rejectStart);
1034
- server.listen(port, host, () => {
1035
- runtime.emit('runtime.http.started', { host, port });
1036
- const saved = normalizeRuntimeAuthSession(options.savedSession, { requireRefreshToken: true });
1037
- if (saved.ok) {
1040
+ server.once('error', rejectStart);
1041
+ server.listen(port, host, () => {
1042
+ runtime.emit('runtime.http.started', { host, port });
1043
+ if (initialChoice) {
1038
1044
  setImmediate(() => {
1039
- try {
1040
- if (!writeSavedSession(saved.session)) throw new Error('refresh-token-required');
1041
- state.auth.persisted = true;
1042
- complete({ type: 'google', session: saved.session }, 'Saved sign-in found. Finding the LiveDesk Hub.');
1043
- } catch (error) {
1044
- recordAuthAttempt('rejected', `session-persistence-failed:${normalizeString(error?.message || error, 160)}`);
1045
- }
1045
+ complete(initialChoice, initialChoiceMessage);
1046
1046
  });
1047
+ } else {
1048
+ const saved = normalizeRuntimeAuthSession(options.savedSession, { requireRefreshToken: true });
1049
+ if (saved.ok) {
1050
+ setImmediate(() => {
1051
+ try {
1052
+ if (!writeSavedSession(saved.session)) throw new Error('refresh-token-required');
1053
+ state.auth.persisted = true;
1054
+ complete({ type: 'google', session: saved.session }, 'Saved sign-in found. Finding the LiveDesk Hub.');
1055
+ } catch (error) {
1056
+ recordAuthAttempt('rejected', `session-persistence-failed:${normalizeString(error?.message || error, 160)}`);
1057
+ }
1058
+ });
1059
+ }
1047
1060
  }
1048
- resolveStart();
1049
- });
1061
+ resolveStart();
1062
+ });
1050
1063
  });
1051
1064
 
1052
1065
  return {