@livedesk/hub 0.1.36 → 0.1.37

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.
@@ -1,317 +1,273 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { fetchSignedUpdateManifest } from '@livedesk/runtime-core/signed-update-manifest';
3
-
4
- export const LIVE_DESK_UPDATE_COMMAND = 'livedesk.client-update';
5
- // 0.1.172 delegates to the exact-version, identity-preserving package
6
- // supervisor. 0.1.171 still lacks bounded handoff cancellation, so it must
7
- // use the Hub-owned compatibility bridge too.
8
- // Older dedicated handlers can spawn the new package but cannot prove that the
9
- // same device returned, so the Hub-owned compatibility bridge upgrades them.
10
- export const LIVE_DESK_DEDICATED_CLIENT_UPDATE_MIN_VERSION = '0.1.172';
11
- export const LIVE_DESK_UPDATE_CLIENT_BATCH_SIZE = 5;
12
- export const LIVE_DESK_UPDATE_TARGET_TIMEOUT_MS = 480_000;
13
- export const LIVE_DESK_UPDATE_TIMEOUT_MS = 90 * 60_000;
14
- export const LIVE_DESK_UPDATE_CLIENT_RESTART_STABILITY_MS = 60_000;
15
- export const LIVE_DESK_UPDATE_CHECK_INTERVAL_MS = 60_000;
16
- export const LIVE_DESK_LEGACY_COMMAND_MAX_LENGTH = 3900;
17
-
18
- function cleanVersion(value) {
19
- return String(value || '').trim().replace(/^v/i, '');
20
- }
21
-
22
- function parseVersion(value) {
23
- const cleaned = cleanVersion(value);
24
- const match = cleaned.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
25
- if (!match) return null;
26
- return {
27
- core: [Number(match[1]), Number(match[2]), Number(match[3])],
28
- prerelease: match[4] ? match[4].split('.') : []
29
- };
30
- }
31
-
32
- function comparePrerelease(left, right) {
33
- if (left.length === 0 && right.length === 0) return 0;
34
- if (left.length === 0) return 1;
35
- if (right.length === 0) return -1;
36
- const length = Math.max(left.length, right.length);
37
- for (let index = 0; index < length; index += 1) {
38
- if (left[index] === undefined) return -1;
39
- if (right[index] === undefined) return 1;
40
- const leftNumeric = /^\d+$/.test(left[index]);
41
- const rightNumeric = /^\d+$/.test(right[index]);
42
- if (leftNumeric && rightNumeric) {
43
- const difference = Number(left[index]) - Number(right[index]);
44
- if (difference !== 0) return difference;
45
- continue;
46
- }
47
- if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
48
- const difference = left[index].localeCompare(right[index], 'en');
49
- if (difference !== 0) return difference;
50
- }
51
- return 0;
52
- }
53
-
54
- export function compareVersions(left, right) {
55
- const a = parseVersion(left);
56
- const b = parseVersion(right);
57
- if (!a || !b) return cleanVersion(left).localeCompare(cleanVersion(right), 'en');
58
- for (let index = 0; index < 3; index += 1) {
59
- const difference = a.core[index] - b.core[index];
60
- if (difference !== 0) return difference;
61
- }
62
- return comparePrerelease(a.prerelease, b.prerelease);
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ export const LIVE_DESK_UPDATE_COMMAND = 'livedesk.client-update';
4
+ // 0.1.172 delegates to the exact-version, identity-preserving package
5
+ // supervisor. 0.1.171 still lacks bounded handoff cancellation, so it must
6
+ // use the Hub-owned compatibility bridge too.
7
+ // Older dedicated handlers can spawn the new package but cannot prove that the
8
+ // same device returned, so the Hub-owned compatibility bridge upgrades them.
9
+ export const LIVE_DESK_DEDICATED_CLIENT_UPDATE_MIN_VERSION = '0.1.172';
10
+ export const LIVE_DESK_UPDATE_CLIENT_BATCH_SIZE = 5;
11
+ export const LIVE_DESK_UPDATE_TARGET_TIMEOUT_MS = 480_000;
12
+ export const LIVE_DESK_UPDATE_TIMEOUT_MS = 90 * 60_000;
13
+ export const LIVE_DESK_UPDATE_CLIENT_RESTART_STABILITY_MS = 60_000;
14
+ export const LIVE_DESK_UPDATE_CHECK_INTERVAL_MS = 60_000;
15
+ export const LIVE_DESK_LEGACY_COMMAND_MAX_LENGTH = 3900;
16
+
17
+ function cleanVersion(value) {
18
+ return String(value || '').trim().replace(/^v/i, '');
19
+ }
20
+
21
+ function parseVersion(value) {
22
+ const cleaned = cleanVersion(value);
23
+ const match = cleaned.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
24
+ if (!match) return null;
25
+ return {
26
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
27
+ prerelease: match[4] ? match[4].split('.') : []
28
+ };
29
+ }
30
+
31
+ function comparePrerelease(left, right) {
32
+ if (left.length === 0 && right.length === 0) return 0;
33
+ if (left.length === 0) return 1;
34
+ if (right.length === 0) return -1;
35
+ const length = Math.max(left.length, right.length);
36
+ for (let index = 0; index < length; index += 1) {
37
+ if (left[index] === undefined) return -1;
38
+ if (right[index] === undefined) return 1;
39
+ const leftNumeric = /^\d+$/.test(left[index]);
40
+ const rightNumeric = /^\d+$/.test(right[index]);
41
+ if (leftNumeric && rightNumeric) {
42
+ const difference = Number(left[index]) - Number(right[index]);
43
+ if (difference !== 0) return difference;
44
+ continue;
45
+ }
46
+ if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
47
+ const difference = left[index].localeCompare(right[index], 'en');
48
+ if (difference !== 0) return difference;
49
+ }
50
+ return 0;
51
+ }
52
+
53
+ export function compareVersions(left, right) {
54
+ const a = parseVersion(left);
55
+ const b = parseVersion(right);
56
+ if (!a || !b) return cleanVersion(left).localeCompare(cleanVersion(right), 'en');
57
+ for (let index = 0; index < 3; index += 1) {
58
+ const difference = a.core[index] - b.core[index];
59
+ if (difference !== 0) return difference;
60
+ }
61
+ return comparePrerelease(a.prerelease, b.prerelease);
63
62
  }
64
63
 
65
64
  export function isVersionAtLeast(candidate, required) {
66
65
  return !!cleanVersion(candidate) && !!cleanVersion(required) && compareVersions(candidate, required) >= 0;
67
66
  }
68
67
 
69
- function encodePowerShell(value) {
70
- return Buffer.from(String(value || ''), 'utf16le').toString('base64');
71
- }
72
-
73
- function buildLegacyPackageSupervisorStarterSource() {
74
- const source = [
75
- "const{spawn}=require('node:child_process'),f=require('node:fs'),p=require('node:path'),c=JSON.parse(Buffer.from(process.env.C,'base64')),e={...process.env,LIVEDESK_UPDATE_STARTER_PID:String(process.pid),LIVEDESK_UPDATE_ORIGINAL_CWD:process.cwd()},w=p.join(require('node:os').tmpdir(),'livedesk-update-cwd','client-'+c[0]),u=e.LIVEDESK_NPX_EXECUTABLE,s=process.platform,j=process.execPath,n=u||(s==='win32'?'npx.cmd':'npx'),x=[e.LIVEDESK_NPX_CLI_PATH,e.npm_execpath&&p.join(p.dirname(e.npm_execpath),'npx-cli.js'),u&&p.join(p.dirname(u),'node_modules/npm/bin/npx-cli.js'),p.join(p.dirname(j),'node_modules/npm/bin/npx-cli.js')].find(v=>v&&f.existsSync(v)),a=['-y','--prefer-online','--prefix',w,'--workspaces=false','livedesk@'+c[6],'--internal-legacy-client-update'],z=String.fromCharCode(32),q=v=>'\"'+String(v).replaceAll('\"','\"\"')+'\"';if(!(+c[7]>0))process.exit(1);c[7]=String(Date.now()+Number(c[7]));e.C=Buffer.from(JSON.stringify(c)).toString('base64');f.mkdirSync(w,{recursive:true});if(f.readdirSync(w)[0])process.exit(1);Object.keys(e).forEach(k=>/^(INIT_CWD|npm_config_(local_prefix|workspaces?|include_workspace_root))$/i.test(k)&&Reflect.deleteProperty(e,k));Object.assign(e,{INIT_CWD:w,npm_config_local_prefix:w,npm_config_workspaces:'false',npm_config_include_workspace_root:'false',LIVEDESK_UPDATE_NEUTRAL_CWD:w});e.i=x?{c:j,a:[x,...a]}:s==='win32'?{c:e.ComSpec||'cmd.exe',a:['/d','/s','/c','call'+z+q(n)+z+a.map(q).join(z)]}:{c:n,a};e.r=spawn(e.i.c,e.i.a,{cwd:w,env:e,detached:true,stdio:'ignore',windowsHide:true});",
76
- "e.r.on('error',()=>process.exit(1));e.r.unref();"
77
- ].join('');
78
- if (source.includes(' ')) throw new Error('LiveDesk legacy starter source must not contain spaces.');
79
- return source;
80
- }
81
-
82
- function assertLegacyCommandLength(command, platform) {
83
- if (command.length > LIVE_DESK_LEGACY_COMMAND_MAX_LENGTH) {
84
- throw new Error(
85
- `LiveDesk legacy ${platform} update command is ${command.length} characters; `
86
- + `the compatibility limit is ${LIVE_DESK_LEGACY_COMMAND_MAX_LENGTH}.`
87
- );
88
- }
89
- return command;
90
- }
91
-
92
- function buildLegacyPlatformCommand(platform, payload) {
93
- const environment = [
94
- String(payload.operationId || ''),
95
- String(payload.deviceId || ''),
96
- String(Number(payload.agentPid) || 0),
97
- cleanVersion(payload.currentProductVersion),
98
- cleanVersion(payload.currentAgentVersion),
99
- cleanVersion(payload.targetVersion),
100
- cleanVersion(payload.targetProductVersion),
101
- String(Math.floor(Number(payload.updateTimeoutMs) || 0))
102
- ];
103
- const configBase64 = Buffer.from(JSON.stringify(environment), 'utf8').toString('base64');
104
- const starter = buildLegacyPackageSupervisorStarterSource();
105
- const starterBase64 = Buffer.from(starter, 'utf8').toString('base64');
106
- const windows = ['win32', 'windows'].includes(String(platform || '').toLowerCase());
107
- const powerShellStarter = [
108
- "$ErrorActionPreference='Stop'",
109
- '$n=[string]$env:LIVEDESK_NODE_EXECUTABLE',
110
- 'if(-not $n){$n=(Get-Command node.exe -ErrorAction SilentlyContinue).Source}',
111
- "if(-not $n){throw 'LiveDesk node executable was not found.'}",
112
- "$j=\"eval(Buffer.from(process.env.S,'base64').toString('utf8'))\"",
113
- "$p=Start-Process -FilePath $n -ArgumentList @('-e',$j) -WindowStyle Hidden -PassThru",
114
- "if(-not $p){throw 'LiveDesk update starter failed.'}"
115
- ].join(';');
116
- const command = windows
117
- ? `set C=${configBase64}&set S=${starterBase64}&powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShell(powerShellStarter)}`
118
- : `C='${configBase64}' S='${starterBase64}' "${'${LIVEDESK_NODE_EXECUTABLE:-node}'}" -e 'eval(Buffer.from(process.env.S,"base64").toString("utf8"))'`;
119
- return assertLegacyCommandLength(command, windows ? 'Windows' : 'Unix');
120
- }
121
-
122
- function buildLegacyUpdateCommand(
123
- device,
124
- operationId,
125
- targetVersion,
126
- targetProductVersion,
127
- updateTimeoutMs
128
- ) {
129
- const payload = {
130
- operationId,
131
- deviceId: device.deviceId || '',
132
- agentPid: device.pid || 0,
133
- currentProductVersion: device.productVersion || '',
134
- currentAgentVersion: device.agentVersion || '',
135
- targetVersion,
136
- targetProductVersion,
137
- updateTimeoutMs
138
- };
139
- return buildLegacyPlatformCommand(device.platform, payload);
140
- }
141
-
142
- async function fetchPackageMetadata(packageName, version, fetchImpl) {
143
- const encodedName = packageName.startsWith('@') ? packageName.replace('/', '%2F') : packageName;
144
- const encodedVersion = encodeURIComponent(String(version || 'latest'));
145
- const response = await fetchImpl(`https://registry.npmjs.org/${encodedName}/${encodedVersion}`, {
68
+ function encodePowerShell(value) {
69
+ return Buffer.from(String(value || ''), 'utf16le').toString('base64');
70
+ }
71
+
72
+ function buildLegacyPackageSupervisorStarterSource() {
73
+ const source = [
74
+ "const{spawn}=require('node:child_process'),f=require('node:fs'),p=require('node:path'),c=JSON.parse(Buffer.from(process.env.C,'base64')),e={...process.env,LIVEDESK_UPDATE_STARTER_PID:String(process.pid),LIVEDESK_UPDATE_ORIGINAL_CWD:process.cwd()},w=p.join(require('node:os').tmpdir(),'livedesk-update-cwd','client-'+c[0]),u=e.LIVEDESK_NPX_EXECUTABLE,s=process.platform,j=process.execPath,n=u||(s==='win32'?'npx.cmd':'npx'),x=[e.LIVEDESK_NPX_CLI_PATH,e.npm_execpath&&p.join(p.dirname(e.npm_execpath),'npx-cli.js'),u&&p.join(p.dirname(u),'node_modules/npm/bin/npx-cli.js'),p.join(p.dirname(j),'node_modules/npm/bin/npx-cli.js')].find(v=>v&&f.existsSync(v)),a=['-y','--prefer-online','--prefix',w,'--workspaces=false','livedesk@'+c[6],'--internal-legacy-client-update'],z=String.fromCharCode(32),q=v=>'\"'+String(v).replaceAll('\"','\"\"')+'\"';if(!(+c[7]>0))process.exit(1);c[7]=String(Date.now()+Number(c[7]));e.C=Buffer.from(JSON.stringify(c)).toString('base64');f.mkdirSync(w,{recursive:true});if(f.readdirSync(w)[0])process.exit(1);Object.keys(e).forEach(k=>/^(INIT_CWD|npm_config_(local_prefix|workspaces?|include_workspace_root))$/i.test(k)&&Reflect.deleteProperty(e,k));Object.assign(e,{INIT_CWD:w,npm_config_local_prefix:w,npm_config_workspaces:'false',npm_config_include_workspace_root:'false',LIVEDESK_UPDATE_NEUTRAL_CWD:w});e.i=x?{c:j,a:[x,...a]}:s==='win32'?{c:e.ComSpec||'cmd.exe',a:['/d','/s','/c','call'+z+q(n)+z+a.map(q).join(z)]}:{c:n,a};e.r=spawn(e.i.c,e.i.a,{cwd:w,env:e,detached:true,stdio:'ignore',windowsHide:true});",
75
+ "e.r.on('error',()=>process.exit(1));e.r.unref();"
76
+ ].join('');
77
+ if (source.includes(' ')) throw new Error('LiveDesk legacy starter source must not contain spaces.');
78
+ return source;
79
+ }
80
+
81
+ function assertLegacyCommandLength(command, platform) {
82
+ if (command.length > LIVE_DESK_LEGACY_COMMAND_MAX_LENGTH) {
83
+ throw new Error(
84
+ `LiveDesk legacy ${platform} update command is ${command.length} characters; `
85
+ + `the compatibility limit is ${LIVE_DESK_LEGACY_COMMAND_MAX_LENGTH}.`
86
+ );
87
+ }
88
+ return command;
89
+ }
90
+
91
+ function buildLegacyPlatformCommand(platform, payload) {
92
+ const environment = [
93
+ String(payload.operationId || ''),
94
+ String(payload.deviceId || ''),
95
+ String(Number(payload.agentPid) || 0),
96
+ cleanVersion(payload.currentProductVersion),
97
+ cleanVersion(payload.currentAgentVersion),
98
+ cleanVersion(payload.targetVersion),
99
+ cleanVersion(payload.targetProductVersion),
100
+ String(Math.floor(Number(payload.updateTimeoutMs) || 0))
101
+ ];
102
+ const configBase64 = Buffer.from(JSON.stringify(environment), 'utf8').toString('base64');
103
+ const starter = buildLegacyPackageSupervisorStarterSource();
104
+ const starterBase64 = Buffer.from(starter, 'utf8').toString('base64');
105
+ const windows = ['win32', 'windows'].includes(String(platform || '').toLowerCase());
106
+ const powerShellStarter = [
107
+ "$ErrorActionPreference='Stop'",
108
+ '$n=[string]$env:LIVEDESK_NODE_EXECUTABLE',
109
+ 'if(-not $n){$n=(Get-Command node.exe -ErrorAction SilentlyContinue).Source}',
110
+ "if(-not $n){throw 'LiveDesk node executable was not found.'}",
111
+ "$j=\"eval(Buffer.from(process.env.S,'base64').toString('utf8'))\"",
112
+ "$p=Start-Process -FilePath $n -ArgumentList @('-e',$j) -WindowStyle Hidden -PassThru",
113
+ "if(-not $p){throw 'LiveDesk update starter failed.'}"
114
+ ].join(';');
115
+ const command = windows
116
+ ? `set C=${configBase64}&set S=${starterBase64}&powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShell(powerShellStarter)}`
117
+ : `C='${configBase64}' S='${starterBase64}' "${'${LIVEDESK_NODE_EXECUTABLE:-node}'}" -e 'eval(Buffer.from(process.env.S,"base64").toString("utf8"))'`;
118
+ return assertLegacyCommandLength(command, windows ? 'Windows' : 'Unix');
119
+ }
120
+
121
+ function buildLegacyUpdateCommand(
122
+ device,
123
+ operationId,
124
+ targetVersion,
125
+ targetProductVersion,
126
+ updateTimeoutMs
127
+ ) {
128
+ const payload = {
129
+ operationId,
130
+ deviceId: device.deviceId || '',
131
+ agentPid: device.pid || 0,
132
+ currentProductVersion: device.productVersion || '',
133
+ currentAgentVersion: device.agentVersion || '',
134
+ targetVersion,
135
+ targetProductVersion,
136
+ updateTimeoutMs
137
+ };
138
+ return buildLegacyPlatformCommand(device.platform, payload);
139
+ }
140
+
141
+ async function fetchLatestPackage(packageName, fetchImpl) {
142
+ const encodedName = packageName.startsWith('@') ? packageName.replace('/', '%2F') : packageName;
143
+ const response = await fetchImpl(`https://registry.npmjs.org/${encodedName}/latest`, {
146
144
  headers: { Accept: 'application/json' },
147
145
  signal: AbortSignal.timeout(12_000)
148
146
  });
149
147
  if (!response.ok) {
150
- throw new Error(`npm registry returned HTTP ${response.status} for ${packageName}@${version}.`);
148
+ throw new Error(`npm registry returned HTTP ${response.status} for ${packageName}.`);
151
149
  }
152
150
  const payload = await response.json();
153
- const resolvedVersion = cleanVersion(payload?.version);
154
- if (!resolvedVersion) throw new Error(`npm registry returned no version for ${packageName}@${version}.`);
155
- return { version: resolvedVersion, package: payload };
156
- }
157
-
158
- async function fetchUnsignedRegistryReleaseForTests(fetchImpl) {
159
- const [manager, client] = await Promise.all([
160
- fetchPackageMetadata('livedesk', 'latest', fetchImpl),
161
- fetchPackageMetadata('@livedesk/client', 'latest', fetchImpl)
162
- ]);
163
- const bundledClientVersion = cleanVersion(manager.package?.livedeskClientVersion);
164
- if (!bundledClientVersion) {
165
- throw new Error(`livedesk@${manager.version} is missing required livedeskClientVersion release metadata.`);
166
- }
167
- return {
168
- latestManagerVersion: manager.version,
169
- latestClientVersion: bundledClientVersion,
170
- registryClientVersion: client.version,
171
- releaseSkew: compareVersions(client.version, bundledClientVersion) === 0
172
- ? ''
173
- : `livedesk@${manager.version} bundles Client ${bundledClientVersion}, while @livedesk/client latest is ${client.version}.`,
174
- managerPackage: manager.package,
175
- clientPackage: client.package,
176
- checkedAt: new Date().toISOString(),
177
- manifestVerified: false,
178
- blockedVersions: { manager: [], client: [], desktop: [] }
179
- };
180
- }
181
-
182
- export async function fetchLatestLiveDeskRelease(fetchImpl = globalThis.fetch, {
183
- manifestUrl = '',
184
- publicKey = '',
185
- now = () => Date.now(),
186
- allowUnsignedRegistryForTests = false
187
- } = {}) {
188
- if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable for LiveDesk update checks.');
189
- if (allowUnsignedRegistryForTests === true) return fetchUnsignedRegistryReleaseForTests(fetchImpl);
190
- const manifest = await fetchSignedUpdateManifest({ fetchImpl, url: manifestUrl, publicKey, now });
191
- const managerRelease = manifest.release.manager;
192
- const clientRelease = manifest.release.client;
193
- const [manager, client] = await Promise.all([
194
- fetchPackageMetadata('livedesk', managerRelease.version, fetchImpl),
195
- fetchPackageMetadata('@livedesk/client', clientRelease.version, fetchImpl)
196
- ]);
197
- const bundledClientVersion = cleanVersion(manager.package?.livedeskClientVersion);
198
- if (manager.version !== managerRelease.version
199
- || client.version !== clientRelease.version
200
- || manager.package?.dist?.integrity !== managerRelease.integrity
201
- || client.package?.dist?.integrity !== clientRelease.integrity
202
- || bundledClientVersion !== clientRelease.version) {
203
- throw new Error('signed-update-registry-binding-invalid');
204
- }
205
- return {
206
- latestManagerVersion: managerRelease.version,
207
- latestClientVersion: clientRelease.version,
208
- registryClientVersion: client.version,
209
- releaseSkew: '',
210
- managerPackage: manager.package,
211
- clientPackage: client.package,
212
- checkedAt: new Date(now()).toISOString(),
213
- manifestVerified: true,
214
- manifestGeneratedAt: manifest.generatedAt,
215
- manifestExpiresAt: manifest.expiresAt,
216
- blockedVersions: manifest.blockedVersions
217
- };
218
- }
219
-
220
- function statusForRun(run) {
221
- if (!run) return null;
222
- const completedCount = run.targets.filter(target => target.state === 'completed').length;
223
- const failedCount = run.targets.filter(target => target.state === 'failed').length;
224
- const queuedCount = run.targets.filter(target => target.state === 'queued').length;
225
- const pendingOfflineCount = run.targets.filter(target => target.state === 'pending-offline').length;
226
- const activeCount = run.targets.filter(target => target.state === 'waiting').length;
227
- return {
228
- operationId: run.operationId,
229
- state: run.state,
230
- startedAt: run.startedAt,
231
- updatedAt: run.updatedAt,
232
- targetCount: run.targets.length,
233
- completedCount,
234
- waitingCount: run.targets.length - completedCount - failedCount,
235
- activeCount,
236
- queuedCount,
237
- pendingOfflineCount,
238
- failedCount,
239
- batchSize: run.batchSize,
240
- clientRestartStabilityMs: run.clientRestartStabilityMs,
241
- latestManagerVersion: run.latestManagerVersion,
242
- latestClientVersion: run.latestClientVersion,
243
- error: run.error || '',
244
- targets: run.targets.map(target => ({ ...target }))
245
- };
246
- }
247
-
248
- function connectedDevices(remoteHub) {
151
+ const version = cleanVersion(payload?.version);
152
+ if (!version) throw new Error(`npm registry returned no version for ${packageName}.`);
153
+ return { version, package: payload };
154
+ }
155
+
156
+ export async function fetchLatestLiveDeskRelease(fetchImpl = globalThis.fetch) {
157
+ if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable for LiveDesk update checks.');
158
+ const [manager, client] = await Promise.all([
159
+ fetchLatestPackage('livedesk', fetchImpl),
160
+ fetchLatestPackage('@livedesk/client', fetchImpl)
161
+ ]);
162
+ const bundledClientVersion = cleanVersion(manager.package?.livedeskClientVersion);
163
+ if (!bundledClientVersion) {
164
+ throw new Error(`livedesk@${manager.version} is missing required livedeskClientVersion release metadata.`);
165
+ }
166
+ return {
167
+ latestManagerVersion: manager.version,
168
+ latestClientVersion: bundledClientVersion,
169
+ registryClientVersion: client.version,
170
+ releaseSkew: compareVersions(client.version, bundledClientVersion) === 0
171
+ ? ''
172
+ : `livedesk@${manager.version} bundles Client ${bundledClientVersion}, while @livedesk/client latest is ${client.version}.`,
173
+ managerPackage: manager.package,
174
+ clientPackage: client.package,
175
+ checkedAt: new Date().toISOString()
176
+ };
177
+ }
178
+
179
+ function statusForRun(run) {
180
+ if (!run) return null;
181
+ const completedCount = run.targets.filter(target => target.state === 'completed').length;
182
+ const failedCount = run.targets.filter(target => target.state === 'failed').length;
183
+ const queuedCount = run.targets.filter(target => target.state === 'queued').length;
184
+ const pendingOfflineCount = run.targets.filter(target => target.state === 'pending-offline').length;
185
+ const activeCount = run.targets.filter(target => target.state === 'waiting').length;
186
+ return {
187
+ operationId: run.operationId,
188
+ state: run.state,
189
+ startedAt: run.startedAt,
190
+ updatedAt: run.updatedAt,
191
+ targetCount: run.targets.length,
192
+ completedCount,
193
+ waitingCount: run.targets.length - completedCount - failedCount,
194
+ activeCount,
195
+ queuedCount,
196
+ pendingOfflineCount,
197
+ failedCount,
198
+ batchSize: run.batchSize,
199
+ clientRestartStabilityMs: run.clientRestartStabilityMs,
200
+ latestManagerVersion: run.latestManagerVersion,
201
+ latestClientVersion: run.latestClientVersion,
202
+ error: run.error || '',
203
+ targets: run.targets.map(target => ({ ...target }))
204
+ };
205
+ }
206
+
207
+ function connectedDevices(remoteHub) {
249
208
  return remoteHub.listDevices({ includeDataUrl: false })
250
209
  .filter(device => device.connected === true && device.synthetic !== true)
251
210
  .slice(0, 500);
252
- }
253
-
254
- function knownOfflineDevices(remoteHub) {
255
- return remoteHub.listDevices({ includeDataUrl: false })
256
- .filter(device => device.connected !== true && device.synthetic !== true)
257
- .slice(0, 500);
258
- }
259
-
260
- function deviceNeedsUpdate(device, latestManagerVersion, latestClientVersion) {
261
- return !isVersionAtLeast(device?.productVersion, latestManagerVersion)
262
- || !isVersionAtLeast(device?.agentVersion, latestClientVersion);
263
- }
264
-
265
- function resetReconnectCandidate(target) {
266
- target.candidateSessionId = '';
267
- target.candidatePid = 0;
268
- target.candidateConnectedAt = '';
269
- target.candidateProductVersion = '';
270
- target.candidateAgentVersion = '';
271
- target.candidateSince = '';
272
- target.stabilityDeadlineAt = '';
273
- }
274
-
275
- export function createLiveDeskUpdateManager({
211
+ }
212
+
213
+ function knownOfflineDevices(remoteHub) {
214
+ return remoteHub.listDevices({ includeDataUrl: false })
215
+ .filter(device => device.connected !== true && device.synthetic !== true)
216
+ .slice(0, 500);
217
+ }
218
+
219
+ function deviceNeedsUpdate(device, latestManagerVersion, latestClientVersion) {
220
+ return !isVersionAtLeast(device?.productVersion, latestManagerVersion)
221
+ || !isVersionAtLeast(device?.agentVersion, latestClientVersion);
222
+ }
223
+
224
+ function resetReconnectCandidate(target) {
225
+ target.candidateSessionId = '';
226
+ target.candidatePid = 0;
227
+ target.candidateConnectedAt = '';
228
+ target.candidateProductVersion = '';
229
+ target.candidateAgentVersion = '';
230
+ target.candidateSince = '';
231
+ target.stabilityDeadlineAt = '';
232
+ }
233
+
234
+ export function createLiveDeskUpdateManager({
276
235
  remoteHub,
277
236
  currentManagerVersion,
278
- currentClientVersion,
279
- restartSupported = false,
280
- requestHubRestart,
281
- fetchImpl = globalThis.fetch,
282
- now = () => Date.now(),
283
- updateManifestUrl = '',
284
- updatePublicKey = '',
285
- allowUnsignedRegistryForTests = false,
286
- clientBatchSize = LIVE_DESK_UPDATE_CLIENT_BATCH_SIZE,
287
- targetTimeoutMs = LIVE_DESK_UPDATE_TARGET_TIMEOUT_MS,
288
- operationTimeoutMs = LIVE_DESK_UPDATE_TIMEOUT_MS,
289
- clientRestartStabilityMs = LIVE_DESK_UPDATE_CLIENT_RESTART_STABILITY_MS
290
- }) {
237
+ currentClientVersion,
238
+ restartSupported = false,
239
+ requestHubRestart,
240
+ fetchImpl = globalThis.fetch,
241
+ now = () => Date.now(),
242
+ clientBatchSize = LIVE_DESK_UPDATE_CLIENT_BATCH_SIZE,
243
+ targetTimeoutMs = LIVE_DESK_UPDATE_TARGET_TIMEOUT_MS,
244
+ operationTimeoutMs = LIVE_DESK_UPDATE_TIMEOUT_MS,
245
+ clientRestartStabilityMs = LIVE_DESK_UPDATE_CLIENT_RESTART_STABILITY_MS
246
+ }) {
291
247
  let latestRelease = null;
292
248
  let checkError = '';
293
249
  let checkPromise = null;
294
- let run = null;
295
- let checkTimer = null;
296
- let runTimer = null;
297
- const effectiveClientBatchSize = Math.max(
298
- 1,
299
- Math.min(50, Math.floor(Number(clientBatchSize) || LIVE_DESK_UPDATE_CLIENT_BATCH_SIZE))
300
- );
301
- const effectiveTargetTimeoutMs = Math.max(
302
- 10_000,
303
- Number(targetTimeoutMs) || LIVE_DESK_UPDATE_TARGET_TIMEOUT_MS
304
- );
305
- const effectiveOperationTimeoutMs = Math.max(
306
- effectiveTargetTimeoutMs,
307
- Number(operationTimeoutMs) || LIVE_DESK_UPDATE_TIMEOUT_MS
308
- );
309
- const effectiveClientRestartStabilityMs = Math.max(
310
- 0,
311
- Number.isFinite(Number(clientRestartStabilityMs))
312
- ? Number(clientRestartStabilityMs)
313
- : LIVE_DESK_UPDATE_CLIENT_RESTART_STABILITY_MS
314
- );
250
+ let run = null;
251
+ let checkTimer = null;
252
+ let runTimer = null;
253
+ const effectiveClientBatchSize = Math.max(
254
+ 1,
255
+ Math.min(50, Math.floor(Number(clientBatchSize) || LIVE_DESK_UPDATE_CLIENT_BATCH_SIZE))
256
+ );
257
+ const effectiveTargetTimeoutMs = Math.max(
258
+ 10_000,
259
+ Number(targetTimeoutMs) || LIVE_DESK_UPDATE_TARGET_TIMEOUT_MS
260
+ );
261
+ const effectiveOperationTimeoutMs = Math.max(
262
+ effectiveTargetTimeoutMs,
263
+ Number(operationTimeoutMs) || LIVE_DESK_UPDATE_TIMEOUT_MS
264
+ );
265
+ const effectiveClientRestartStabilityMs = Math.max(
266
+ 0,
267
+ Number.isFinite(Number(clientRestartStabilityMs))
268
+ ? Number(clientRestartStabilityMs)
269
+ : LIVE_DESK_UPDATE_CLIENT_RESTART_STABILITY_MS
270
+ );
315
271
 
316
272
  const touch = () => {
317
273
  if (run) run.updatedAt = new Date(now()).toISOString();
@@ -319,30 +275,9 @@ export function createLiveDeskUpdateManager({
319
275
 
320
276
  const checkLatest = async () => {
321
277
  if (checkPromise) return checkPromise;
322
- checkPromise = fetchLatestLiveDeskRelease(fetchImpl, {
323
- manifestUrl: updateManifestUrl,
324
- publicKey: updatePublicKey,
325
- now,
326
- allowUnsignedRegistryForTests
327
- })
328
- .then(release => {
329
- if (cleanVersion(currentManagerVersion)
330
- && compareVersions(release.latestManagerVersion, currentManagerVersion) < 0) {
331
- throw new Error('signed-update-manager-downgrade-rejected');
332
- }
333
- if (cleanVersion(currentClientVersion)
334
- && compareVersions(release.latestClientVersion, currentClientVersion) < 0) {
335
- throw new Error('signed-update-client-downgrade-rejected');
336
- }
337
- if (release.blockedVersions?.manager?.includes(cleanVersion(currentManagerVersion))
338
- && compareVersions(release.latestManagerVersion, currentManagerVersion) <= 0) {
339
- throw new Error('signed-update-blocked-manager-without-safe-upgrade');
340
- }
341
- if (release.blockedVersions?.client?.includes(cleanVersion(currentClientVersion))
342
- && compareVersions(release.latestClientVersion, currentClientVersion) <= 0) {
343
- throw new Error('signed-update-blocked-client-without-safe-upgrade');
344
- }
345
- latestRelease = release;
278
+ checkPromise = fetchLatestLiveDeskRelease(fetchImpl)
279
+ .then(release => {
280
+ latestRelease = release;
346
281
  checkError = '';
347
282
  return release;
348
283
  })
@@ -357,127 +292,124 @@ export function createLiveDeskUpdateManager({
357
292
  };
358
293
 
359
294
  const getStatus = () => {
360
- const managerUpdateAvailable = !!latestRelease
361
- && compareVersions(latestRelease.latestManagerVersion, currentManagerVersion) > 0;
362
- const clientDevices = connectedDevices(remoteHub);
363
- const outdatedClientDevices = latestRelease
364
- ? clientDevices.filter(device => deviceNeedsUpdate(
365
- device,
366
- latestRelease.latestManagerVersion,
367
- latestRelease.latestClientVersion
368
- ))
369
- : [];
370
- const pendingOfflineClientCount = latestRelease
371
- ? knownOfflineDevices(remoteHub).filter(device => deviceNeedsUpdate(
372
- device,
373
- latestRelease.latestManagerVersion,
374
- latestRelease.latestClientVersion
375
- )).length
376
- : 0;
377
- const clientPackageUpdateAvailable = !!latestRelease
378
- && compareVersions(latestRelease.latestClientVersion, currentClientVersion) > 0;
295
+ const managerUpdateAvailable = !!latestRelease
296
+ && compareVersions(latestRelease.latestManagerVersion, currentManagerVersion) > 0;
297
+ const clientDevices = connectedDevices(remoteHub);
298
+ const outdatedClientDevices = latestRelease
299
+ ? clientDevices.filter(device => deviceNeedsUpdate(
300
+ device,
301
+ latestRelease.latestManagerVersion,
302
+ latestRelease.latestClientVersion
303
+ ))
304
+ : [];
305
+ const pendingOfflineClientCount = latestRelease
306
+ ? knownOfflineDevices(remoteHub).filter(device => deviceNeedsUpdate(
307
+ device,
308
+ latestRelease.latestManagerVersion,
309
+ latestRelease.latestClientVersion
310
+ )).length
311
+ : 0;
312
+ const clientPackageUpdateAvailable = !!latestRelease
313
+ && compareVersions(latestRelease.latestClientVersion, currentClientVersion) > 0;
379
314
  const clientUpdateAvailable = clientPackageUpdateAvailable || outdatedClientDevices.length > 0;
380
315
  const updateAvailable = managerUpdateAvailable || clientUpdateAvailable;
381
316
  const activeRun = statusForRun(run);
382
317
  return {
383
318
  currentVersion: String(currentManagerVersion || ''),
384
319
  currentClientVersion: String(currentClientVersion || ''),
385
- latestVersion: latestRelease?.latestManagerVersion || '',
386
- latestClientVersion: latestRelease?.latestClientVersion || '',
387
- registryClientVersion: latestRelease?.registryClientVersion || '',
388
- releaseSkew: latestRelease?.releaseSkew || '',
389
- manifestVerified: latestRelease?.manifestVerified === true,
390
- manifestGeneratedAt: latestRelease?.manifestGeneratedAt || '',
391
- manifestExpiresAt: latestRelease?.manifestExpiresAt || '',
392
- updateAvailable,
320
+ latestVersion: latestRelease?.latestManagerVersion || '',
321
+ latestClientVersion: latestRelease?.latestClientVersion || '',
322
+ registryClientVersion: latestRelease?.registryClientVersion || '',
323
+ releaseSkew: latestRelease?.releaseSkew || '',
324
+ updateAvailable,
393
325
  managerUpdateAvailable,
394
326
  clientUpdateAvailable,
395
- clientPackageUpdateAvailable,
396
- outdatedClientCount: outdatedClientDevices.length,
397
- pendingOfflineClientCount,
398
- checkedAt: latestRelease?.checkedAt || '',
327
+ clientPackageUpdateAvailable,
328
+ outdatedClientCount: outdatedClientDevices.length,
329
+ pendingOfflineClientCount,
330
+ checkedAt: latestRelease?.checkedAt || '',
399
331
  checkError,
400
332
  restartSupported: !!restartSupported,
401
333
  canApply: updateAvailable && (!managerUpdateAvailable && !clientPackageUpdateAvailable || !!restartSupported),
402
- ...(activeRun || {
403
- state: 'idle',
404
- operationId: '',
405
- targetCount: 0,
406
- completedCount: 0,
407
- waitingCount: 0,
408
- activeCount: 0,
409
- queuedCount: 0,
410
- pendingOfflineCount: 0,
411
- failedCount: 0,
412
- batchSize: effectiveClientBatchSize,
413
- clientRestartStabilityMs: effectiveClientRestartStabilityMs,
414
- targets: []
415
- })
334
+ ...(activeRun || {
335
+ state: 'idle',
336
+ operationId: '',
337
+ targetCount: 0,
338
+ completedCount: 0,
339
+ waitingCount: 0,
340
+ activeCount: 0,
341
+ queuedCount: 0,
342
+ pendingOfflineCount: 0,
343
+ failedCount: 0,
344
+ batchSize: effectiveClientBatchSize,
345
+ clientRestartStabilityMs: effectiveClientRestartStabilityMs,
346
+ targets: []
347
+ })
416
348
  };
417
349
  };
418
350
 
419
- const failRun = (message) => {
420
- if (!run) return;
421
- run.state = 'failed';
422
- run.error = String(message || 'LiveDesk update failed.');
423
- for (const target of run.targets) {
424
- if (target.state === 'completed') continue;
425
- target.state = 'failed';
426
- if (!target.error) {
427
- target.error = `Rollout stopped: ${run.error}`;
428
- }
429
- }
430
- touch();
431
- if (runTimer) {
432
- clearInterval(runTimer);
433
- runTimer = null;
434
- }
435
- };
436
-
437
- const reconcileHubRestartResult = (restartResult) => {
438
- if (!run || run.state !== 'hub-restart-requested') return false;
439
- const operationId = String(restartResult?.operationId || '').trim();
440
- if (!operationId || operationId !== run.operationId) return false;
441
- const stage = String(restartResult?.stage || '').trim();
442
- const outcome = String(restartResult?.outcome || '').trim();
443
- if (outcome !== 'failed' || !['failed', 'preflight-failed'].includes(stage)) return false;
444
- failRun(
445
- restartResult?.error
446
- || (stage === 'preflight-failed'
447
- ? 'Hub update restart preflight failed before shutdown.'
448
- : 'Hub update restart failed.')
449
- );
450
- return true;
451
- };
452
-
453
- const retrySettlingTargets = () => {
454
- if (!run || run.state !== 'failed') return [];
455
- const currentTime = now();
456
- const devices = new Map(
457
- remoteHub.listDevices({ includeDataUrl: false })
458
- .map(device => [String(device.deviceId), device])
459
- );
460
- return run.targets.filter(target => {
461
- if (target.state !== 'failed' || !target.dispatchedAt) return false;
462
- const deadline = Date.parse(target.deadlineAt || '');
463
- if (!(deadline > currentTime)) return false;
464
- return devices.get(target.deviceId)?.connected !== true;
465
- });
466
- };
351
+ const failRun = (message) => {
352
+ if (!run) return;
353
+ run.state = 'failed';
354
+ run.error = String(message || 'LiveDesk update failed.');
355
+ for (const target of run.targets) {
356
+ if (target.state === 'completed') continue;
357
+ target.state = 'failed';
358
+ if (!target.error) {
359
+ target.error = `Rollout stopped: ${run.error}`;
360
+ }
361
+ }
362
+ touch();
363
+ if (runTimer) {
364
+ clearInterval(runTimer);
365
+ runTimer = null;
366
+ }
367
+ };
368
+
369
+ const reconcileHubRestartResult = (restartResult) => {
370
+ if (!run || run.state !== 'hub-restart-requested') return false;
371
+ const operationId = String(restartResult?.operationId || '').trim();
372
+ if (!operationId || operationId !== run.operationId) return false;
373
+ const stage = String(restartResult?.stage || '').trim();
374
+ const outcome = String(restartResult?.outcome || '').trim();
375
+ if (outcome !== 'failed' || !['failed', 'preflight-failed'].includes(stage)) return false;
376
+ failRun(
377
+ restartResult?.error
378
+ || (stage === 'preflight-failed'
379
+ ? 'Hub update restart preflight failed before shutdown.'
380
+ : 'Hub update restart failed.')
381
+ );
382
+ return true;
383
+ };
384
+
385
+ const retrySettlingTargets = () => {
386
+ if (!run || run.state !== 'failed') return [];
387
+ const currentTime = now();
388
+ const devices = new Map(
389
+ remoteHub.listDevices({ includeDataUrl: false })
390
+ .map(device => [String(device.deviceId), device])
391
+ );
392
+ return run.targets.filter(target => {
393
+ if (target.state !== 'failed' || !target.dispatchedAt) return false;
394
+ const deadline = Date.parse(target.deadlineAt || '');
395
+ if (!(deadline > currentTime)) return false;
396
+ return devices.get(target.deviceId)?.connected !== true;
397
+ });
398
+ };
467
399
 
468
400
  const requestRestart = () => {
469
401
  if (!restartSupported) {
470
402
  failRun('Hub launcher restart is unavailable. Start LiveDesk through npx livedesk@latest.');
471
403
  return false;
472
404
  }
473
- const result = requestHubRestart?.({
474
- operationId: run?.operationId || '',
475
- latestVersion: run?.latestManagerVersion || latestRelease?.latestManagerVersion || '',
476
- latestClientVersion: run?.latestClientVersion || latestRelease?.latestClientVersion || '',
477
- // Current versions update every connected Client before the Hub exits.
478
- // Older Hub versions did the reverse and set this flag themselves; the
479
- // new server still supports that one-time migration path.
480
- continueClientUpdate: false
405
+ const result = requestHubRestart?.({
406
+ operationId: run?.operationId || '',
407
+ latestVersion: run?.latestManagerVersion || latestRelease?.latestManagerVersion || '',
408
+ latestClientVersion: run?.latestClientVersion || latestRelease?.latestClientVersion || '',
409
+ // Current versions update every connected Client before the Hub exits.
410
+ // Older Hub versions did the reverse and set this flag themselves; the
411
+ // new server still supports that one-time migration path.
412
+ continueClientUpdate: false
481
413
  });
482
414
  if (result?.ok !== true) {
483
415
  failRun(result?.error || 'Hub launcher restart request failed.');
@@ -490,214 +422,214 @@ export function createLiveDeskUpdateManager({
490
422
  return true;
491
423
  };
492
424
 
493
- const dispatchTarget = (target, device) => {
494
- const dispatchedAtEpochMs = now();
495
- const updateDeadlineEpochMs = dispatchedAtEpochMs + effectiveTargetTimeoutMs;
496
- const dispatchedSessionId = String(device?.sessionId || '').trim();
497
- if (!dispatchedSessionId) {
498
- target.state = 'failed';
499
- target.error = 'client-update-session-unavailable';
500
- return false;
501
- }
502
- target.dispatchedSessionId = dispatchedSessionId;
503
- target.dispatchedPid = Math.max(0, Math.floor(Number(device?.pid) || 0));
504
- resetReconnectCandidate(target);
505
- const supportsDedicated = device.capabilities?.clientUpdate === true
506
- && isVersionAtLeast(device.agentVersion, LIVE_DESK_DEDICATED_CLIENT_UPDATE_MIN_VERSION);
507
- const payload = {
508
- targetVersion: run.latestClientVersion,
509
- targetProductVersion: run.latestManagerVersion,
510
- managerVersion: run.latestManagerVersion,
511
- operationId: run.operationId,
512
- updateTimeoutMs: effectiveTargetTimeoutMs
513
- };
514
- const result = supportsDedicated
515
- ? remoteHub.sendCommand(device.deviceId, { command: LIVE_DESK_UPDATE_COMMAND, payload })
516
- : remoteHub.sendLegacyClientUpdate(device.deviceId, {
517
- command: buildLegacyUpdateCommand(
518
- device,
519
- run.operationId,
520
- run.latestClientVersion,
521
- run.latestManagerVersion,
522
- effectiveTargetTimeoutMs
523
- ),
524
- timeoutMs: 30_000
525
- });
526
- if (result?.ok !== true) {
527
- const dispatchError = String(result?.error || 'client-update-dispatch-failed');
528
- if (['device-not-connected', 'device-not-found'].includes(dispatchError)) {
529
- target.state = 'pending-offline';
530
- target.method = '';
531
- target.commandId = '';
532
- target.dispatchedAt = '';
533
- target.deadlineAt = '';
534
- target.dispatchedSessionId = '';
535
- target.dispatchedPid = 0;
536
- resetReconnectCandidate(target);
537
- target.error = '';
538
- return 'pending-offline';
539
- }
540
- target.state = 'failed';
541
- target.error = dispatchError;
542
- return false;
425
+ const dispatchTarget = (target, device) => {
426
+ const dispatchedAtEpochMs = now();
427
+ const updateDeadlineEpochMs = dispatchedAtEpochMs + effectiveTargetTimeoutMs;
428
+ const dispatchedSessionId = String(device?.sessionId || '').trim();
429
+ if (!dispatchedSessionId) {
430
+ target.state = 'failed';
431
+ target.error = 'client-update-session-unavailable';
432
+ return false;
433
+ }
434
+ target.dispatchedSessionId = dispatchedSessionId;
435
+ target.dispatchedPid = Math.max(0, Math.floor(Number(device?.pid) || 0));
436
+ resetReconnectCandidate(target);
437
+ const supportsDedicated = device.capabilities?.clientUpdate === true
438
+ && isVersionAtLeast(device.agentVersion, LIVE_DESK_DEDICATED_CLIENT_UPDATE_MIN_VERSION);
439
+ const payload = {
440
+ targetVersion: run.latestClientVersion,
441
+ targetProductVersion: run.latestManagerVersion,
442
+ managerVersion: run.latestManagerVersion,
443
+ operationId: run.operationId,
444
+ updateTimeoutMs: effectiveTargetTimeoutMs
445
+ };
446
+ const result = supportsDedicated
447
+ ? remoteHub.sendCommand(device.deviceId, { command: LIVE_DESK_UPDATE_COMMAND, payload })
448
+ : remoteHub.sendLegacyClientUpdate(device.deviceId, {
449
+ command: buildLegacyUpdateCommand(
450
+ device,
451
+ run.operationId,
452
+ run.latestClientVersion,
453
+ run.latestManagerVersion,
454
+ effectiveTargetTimeoutMs
455
+ ),
456
+ timeoutMs: 30_000
457
+ });
458
+ if (result?.ok !== true) {
459
+ const dispatchError = String(result?.error || 'client-update-dispatch-failed');
460
+ if (['device-not-connected', 'device-not-found'].includes(dispatchError)) {
461
+ target.state = 'pending-offline';
462
+ target.method = '';
463
+ target.commandId = '';
464
+ target.dispatchedAt = '';
465
+ target.deadlineAt = '';
466
+ target.dispatchedSessionId = '';
467
+ target.dispatchedPid = 0;
468
+ resetReconnectCandidate(target);
469
+ target.error = '';
470
+ return 'pending-offline';
471
+ }
472
+ target.state = 'failed';
473
+ target.error = dispatchError;
474
+ return false;
475
+ }
476
+ target.state = 'waiting';
477
+ target.method = supportsDedicated ? 'dedicated' : 'legacy-command-run';
478
+ target.commandId = String(result.commandId || result.taskId || '');
479
+ target.dispatchedAt = new Date(dispatchedAtEpochMs).toISOString();
480
+ target.deadlineAt = new Date(updateDeadlineEpochMs).toISOString();
481
+ return true;
482
+ };
483
+
484
+ const dispatchQueuedTargets = () => {
485
+ if (!run || run.state !== 'waiting-for-clients') return true;
486
+ const activeCount = run.targets.filter(target => target.state === 'waiting').length;
487
+ let availableSlots = Math.max(0, run.batchSize - activeCount);
488
+ if (availableSlots <= 0) return true;
489
+ const devices = new Map(connectedDevices(remoteHub).map(device => [String(device.deviceId), device]));
490
+ for (const target of run.targets) {
491
+ if (availableSlots <= 0) break;
492
+ if (!['queued', 'pending-offline'].includes(target.state)) continue;
493
+ const device = devices.get(target.deviceId);
494
+ if (!device?.connected) {
495
+ target.state = 'pending-offline';
496
+ continue;
497
+ }
498
+ target.state = 'queued';
499
+ let dispatchResult;
500
+ try {
501
+ dispatchResult = dispatchTarget(target, device);
502
+ } catch (error) {
503
+ target.state = 'failed';
504
+ target.error = String(error?.message || error || 'client-update-command-build-failed').slice(0, 500);
505
+ failRun(`Client ${target.deviceName || target.deviceId} could not be scheduled: ${target.error}`);
506
+ return false;
507
+ }
508
+ if (dispatchResult === false) {
509
+ failRun(`Client ${target.deviceName || target.deviceId} could not be scheduled: ${target.error}`);
510
+ return false;
511
+ }
512
+ if (dispatchResult === true) {
513
+ availableSlots -= 1;
514
+ }
543
515
  }
544
- target.state = 'waiting';
545
- target.method = supportsDedicated ? 'dedicated' : 'legacy-command-run';
546
- target.commandId = String(result.commandId || result.taskId || '');
547
- target.dispatchedAt = new Date(dispatchedAtEpochMs).toISOString();
548
- target.deadlineAt = new Date(updateDeadlineEpochMs).toISOString();
549
- return true;
550
- };
551
-
552
- const dispatchQueuedTargets = () => {
553
- if (!run || run.state !== 'waiting-for-clients') return true;
554
- const activeCount = run.targets.filter(target => target.state === 'waiting').length;
555
- let availableSlots = Math.max(0, run.batchSize - activeCount);
556
- if (availableSlots <= 0) return true;
557
- const devices = new Map(connectedDevices(remoteHub).map(device => [String(device.deviceId), device]));
558
- for (const target of run.targets) {
559
- if (availableSlots <= 0) break;
560
- if (!['queued', 'pending-offline'].includes(target.state)) continue;
561
- const device = devices.get(target.deviceId);
562
- if (!device?.connected) {
563
- target.state = 'pending-offline';
564
- continue;
565
- }
566
- target.state = 'queued';
567
- let dispatchResult;
568
- try {
569
- dispatchResult = dispatchTarget(target, device);
570
- } catch (error) {
571
- target.state = 'failed';
572
- target.error = String(error?.message || error || 'client-update-command-build-failed').slice(0, 500);
573
- failRun(`Client ${target.deviceName || target.deviceId} could not be scheduled: ${target.error}`);
574
- return false;
575
- }
576
- if (dispatchResult === false) {
577
- failRun(`Client ${target.deviceName || target.deviceId} could not be scheduled: ${target.error}`);
578
- return false;
579
- }
580
- if (dispatchResult === true) {
581
- availableSlots -= 1;
582
- }
583
- }
584
- touch();
585
- return true;
586
- };
587
-
588
- const verifyTargets = () => {
589
- if (!run || run.state !== 'waiting-for-clients') return;
590
- const currentTime = now();
591
- if (currentTime - Date.parse(run.startedAt) >= effectiveOperationTimeoutMs) {
592
- const unfinishedCount = run.targets.filter(target => target.state !== 'completed').length;
593
- failRun(`Timed out waiting for ${unfinishedCount} client(s) to finish the rollout.`);
594
- return;
595
- }
596
- const devices = new Map(connectedDevices(remoteHub).map(device => [String(device.deviceId), device]));
597
- for (const target of run.targets) {
598
- if (target.state === 'failed' || target.state === 'completed') continue;
599
- const device = devices.get(target.deviceId);
600
- if (target.state === 'waiting') {
601
- const sessionId = String(device?.sessionId || '').trim();
602
- const connectedAt = String(device?.connectedAt || '');
603
- const connectedAtEpochMs = Date.parse(connectedAt);
604
- const dispatchedAtEpochMs = Date.parse(target.dispatchedAt || '');
605
- const candidatePid = Math.max(0, Math.floor(Number(device?.pid) || 0));
606
- const candidateProductVersion = String(device?.productVersion || '');
607
- const candidateAgentVersion = String(device?.agentVersion || '');
608
- const qualifiesForStability = device?.connected === true
609
- && !!sessionId
610
- && candidatePid > 1
611
- && sessionId !== target.dispatchedSessionId
612
- && connectedAtEpochMs >= dispatchedAtEpochMs
613
- && !deviceNeedsUpdate(device, run.latestManagerVersion, run.latestClientVersion);
614
- const sameCandidate = qualifiesForStability
615
- && target.candidateSessionId === sessionId
616
- && target.candidatePid === candidatePid
617
- && target.candidateConnectedAt === connectedAt
618
- && target.candidateProductVersion === candidateProductVersion
619
- && target.candidateAgentVersion === candidateAgentVersion;
620
-
621
- if (!qualifiesForStability) {
622
- resetReconnectCandidate(target);
623
- } else if (!sameCandidate) {
624
- target.candidateSessionId = sessionId;
625
- target.candidatePid = candidatePid;
626
- target.candidateConnectedAt = connectedAt;
627
- target.candidateProductVersion = candidateProductVersion;
628
- target.candidateAgentVersion = candidateAgentVersion;
629
- target.candidateSince = new Date(currentTime).toISOString();
630
- target.stabilityDeadlineAt = new Date(
631
- currentTime + run.clientRestartStabilityMs
632
- ).toISOString();
633
- }
634
-
635
- if (qualifiesForStability
636
- && target.candidateSessionId === sessionId
637
- && currentTime >= Date.parse(target.stabilityDeadlineAt || '')) {
638
- target.state = 'completed';
639
- target.currentVersion = String(device.agentVersion || '');
640
- target.currentProductVersion = String(device.productVersion || '');
641
- target.completedAt = new Date(currentTime).toISOString();
642
- continue;
643
- }
644
- }
645
- if (target.state === 'waiting'
646
- && Date.parse(target.deadlineAt || '') > 0
647
- && currentTime >= Date.parse(target.deadlineAt)) {
648
- target.state = 'failed';
649
- target.error = `Timed out waiting for product ${run.latestManagerVersion} / Client ${run.latestClientVersion}.`;
650
- failRun(`Client ${target.deviceName || target.deviceId} did not restart on the requested versions within ${Math.round(effectiveTargetTimeoutMs / 1000)} seconds.`);
651
- return;
652
- }
653
- if (target.state === 'pending-offline' && device?.connected === true) {
654
- target.state = 'queued';
655
- }
656
- }
657
- if (!dispatchQueuedTargets()) return;
658
- touch();
659
- if (run.targets.every(target => target.state === 'completed')) {
660
- if (runTimer) {
661
- clearInterval(runTimer);
662
- runTimer = null;
663
- }
664
- if (run.needsHubRestart) {
665
- requestRestart();
666
- } else {
667
- run.state = 'clients-updated';
668
- touch();
669
- }
670
- return;
671
- }
672
- };
673
-
674
- const startUpdate = async () => {
675
- if (run && ['dispatching', 'waiting-for-clients', 'hub-restart-requested'].includes(run.state)) {
676
- return { ok: true, ...getStatus() };
677
- }
678
- const settlingTargets = retrySettlingTargets();
679
- if (settlingTargets.length > 0) {
680
- const names = settlingTargets
681
- .slice(0, 3)
682
- .map(target => target.deviceName || target.deviceId)
683
- .join(', ');
684
- const remaining = settlingTargets.length > 3 ? ` and ${settlingTargets.length - 3} more` : '';
685
- return {
686
- ...getStatus(),
687
- ok: false,
688
- error: `The previous rollout is still settling for offline Client(s): ${names}${remaining}. Retry after they reconnect or their restart deadline passes.`
689
- };
690
- }
691
- const release = latestRelease || await checkLatest();
516
+ touch();
517
+ return true;
518
+ };
519
+
520
+ const verifyTargets = () => {
521
+ if (!run || run.state !== 'waiting-for-clients') return;
522
+ const currentTime = now();
523
+ if (currentTime - Date.parse(run.startedAt) >= effectiveOperationTimeoutMs) {
524
+ const unfinishedCount = run.targets.filter(target => target.state !== 'completed').length;
525
+ failRun(`Timed out waiting for ${unfinishedCount} client(s) to finish the rollout.`);
526
+ return;
527
+ }
528
+ const devices = new Map(connectedDevices(remoteHub).map(device => [String(device.deviceId), device]));
529
+ for (const target of run.targets) {
530
+ if (target.state === 'failed' || target.state === 'completed') continue;
531
+ const device = devices.get(target.deviceId);
532
+ if (target.state === 'waiting') {
533
+ const sessionId = String(device?.sessionId || '').trim();
534
+ const connectedAt = String(device?.connectedAt || '');
535
+ const connectedAtEpochMs = Date.parse(connectedAt);
536
+ const dispatchedAtEpochMs = Date.parse(target.dispatchedAt || '');
537
+ const candidatePid = Math.max(0, Math.floor(Number(device?.pid) || 0));
538
+ const candidateProductVersion = String(device?.productVersion || '');
539
+ const candidateAgentVersion = String(device?.agentVersion || '');
540
+ const qualifiesForStability = device?.connected === true
541
+ && !!sessionId
542
+ && candidatePid > 1
543
+ && sessionId !== target.dispatchedSessionId
544
+ && connectedAtEpochMs >= dispatchedAtEpochMs
545
+ && !deviceNeedsUpdate(device, run.latestManagerVersion, run.latestClientVersion);
546
+ const sameCandidate = qualifiesForStability
547
+ && target.candidateSessionId === sessionId
548
+ && target.candidatePid === candidatePid
549
+ && target.candidateConnectedAt === connectedAt
550
+ && target.candidateProductVersion === candidateProductVersion
551
+ && target.candidateAgentVersion === candidateAgentVersion;
552
+
553
+ if (!qualifiesForStability) {
554
+ resetReconnectCandidate(target);
555
+ } else if (!sameCandidate) {
556
+ target.candidateSessionId = sessionId;
557
+ target.candidatePid = candidatePid;
558
+ target.candidateConnectedAt = connectedAt;
559
+ target.candidateProductVersion = candidateProductVersion;
560
+ target.candidateAgentVersion = candidateAgentVersion;
561
+ target.candidateSince = new Date(currentTime).toISOString();
562
+ target.stabilityDeadlineAt = new Date(
563
+ currentTime + run.clientRestartStabilityMs
564
+ ).toISOString();
565
+ }
566
+
567
+ if (qualifiesForStability
568
+ && target.candidateSessionId === sessionId
569
+ && currentTime >= Date.parse(target.stabilityDeadlineAt || '')) {
570
+ target.state = 'completed';
571
+ target.currentVersion = String(device.agentVersion || '');
572
+ target.currentProductVersion = String(device.productVersion || '');
573
+ target.completedAt = new Date(currentTime).toISOString();
574
+ continue;
575
+ }
576
+ }
577
+ if (target.state === 'waiting'
578
+ && Date.parse(target.deadlineAt || '') > 0
579
+ && currentTime >= Date.parse(target.deadlineAt)) {
580
+ target.state = 'failed';
581
+ target.error = `Timed out waiting for product ${run.latestManagerVersion} / Client ${run.latestClientVersion}.`;
582
+ failRun(`Client ${target.deviceName || target.deviceId} did not restart on the requested versions within ${Math.round(effectiveTargetTimeoutMs / 1000)} seconds.`);
583
+ return;
584
+ }
585
+ if (target.state === 'pending-offline' && device?.connected === true) {
586
+ target.state = 'queued';
587
+ }
588
+ }
589
+ if (!dispatchQueuedTargets()) return;
590
+ touch();
591
+ if (run.targets.every(target => target.state === 'completed')) {
592
+ if (runTimer) {
593
+ clearInterval(runTimer);
594
+ runTimer = null;
595
+ }
596
+ if (run.needsHubRestart) {
597
+ requestRestart();
598
+ } else {
599
+ run.state = 'clients-updated';
600
+ touch();
601
+ }
602
+ return;
603
+ }
604
+ };
605
+
606
+ const startUpdate = async () => {
607
+ if (run && ['dispatching', 'waiting-for-clients', 'hub-restart-requested'].includes(run.state)) {
608
+ return { ok: true, ...getStatus() };
609
+ }
610
+ const settlingTargets = retrySettlingTargets();
611
+ if (settlingTargets.length > 0) {
612
+ const names = settlingTargets
613
+ .slice(0, 3)
614
+ .map(target => target.deviceName || target.deviceId)
615
+ .join(', ');
616
+ const remaining = settlingTargets.length > 3 ? ` and ${settlingTargets.length - 3} more` : '';
617
+ return {
618
+ ...getStatus(),
619
+ ok: false,
620
+ error: `The previous rollout is still settling for offline Client(s): ${names}${remaining}. Retry after they reconnect or their restart deadline passes.`
621
+ };
622
+ }
623
+ const release = latestRelease || await checkLatest();
692
624
  if (!release) return { ok: false, error: checkError || 'LiveDesk update check failed.', ...getStatus() };
693
625
  const managerNeedsUpdate = compareVersions(release.latestManagerVersion, currentManagerVersion) > 0;
694
626
  const clientPackageNeedsUpdate = compareVersions(release.latestClientVersion, currentClientVersion) > 0;
695
627
  const connected = connectedDevices(remoteHub);
696
- const outdatedClients = connected.filter(device => deviceNeedsUpdate(
697
- device,
698
- release.latestManagerVersion,
699
- release.latestClientVersion
700
- ));
628
+ const outdatedClients = connected.filter(device => deviceNeedsUpdate(
629
+ device,
630
+ release.latestManagerVersion,
631
+ release.latestClientVersion
632
+ ));
701
633
  const needsHubRestart = managerNeedsUpdate || clientPackageNeedsUpdate;
702
634
  if (!managerNeedsUpdate && !clientPackageNeedsUpdate && outdatedClients.length === 0) {
703
635
  return { ok: true, state: 'clients-updated', ...getStatus() };
@@ -705,78 +637,78 @@ export function createLiveDeskUpdateManager({
705
637
  if (needsHubRestart && !restartSupported) {
706
638
  return { ok: false, error: 'Hub launcher restart is unavailable. Start LiveDesk through npx livedesk@latest.', ...getStatus() };
707
639
  }
708
- const targets = outdatedClients;
709
- run = {
640
+ const targets = outdatedClients;
641
+ run = {
710
642
  operationId: randomUUID(),
711
643
  state: 'dispatching',
712
644
  startedAt: new Date(now()).toISOString(),
713
645
  updatedAt: new Date(now()).toISOString(),
714
646
  latestManagerVersion: release.latestManagerVersion,
715
- latestClientVersion: release.latestClientVersion,
716
- needsHubRestart,
717
- batchSize: effectiveClientBatchSize,
718
- clientRestartStabilityMs: effectiveClientRestartStabilityMs,
719
- error: '',
720
- targets: targets.map(device => ({
647
+ latestClientVersion: release.latestClientVersion,
648
+ needsHubRestart,
649
+ batchSize: effectiveClientBatchSize,
650
+ clientRestartStabilityMs: effectiveClientRestartStabilityMs,
651
+ error: '',
652
+ targets: targets.map(device => ({
721
653
  deviceId: String(device.deviceId || ''),
722
- deviceName: String(device.deviceName || device.hostname || device.deviceId || ''),
723
- oldVersion: String(device.agentVersion || ''),
724
- oldProductVersion: String(device.productVersion || ''),
725
- currentVersion: '',
726
- currentProductVersion: '',
727
- state: 'queued',
728
- method: '',
729
- commandId: '',
730
- dispatchedAt: '',
731
- deadlineAt: '',
732
- dispatchedSessionId: '',
733
- dispatchedPid: 0,
734
- candidateSessionId: '',
735
- candidatePid: 0,
736
- candidateConnectedAt: '',
737
- candidateProductVersion: '',
738
- candidateAgentVersion: '',
739
- candidateSince: '',
740
- stabilityDeadlineAt: '',
741
- completedAt: '',
742
- error: ''
743
- }))
744
- };
745
- touch();
746
- if (run.targets.length === 0) {
747
- if (run.needsHubRestart) {
748
- if (!requestRestart()) {
749
- return { ok: false, error: run.error, ...getStatus() };
750
- }
751
- } else {
654
+ deviceName: String(device.deviceName || device.hostname || device.deviceId || ''),
655
+ oldVersion: String(device.agentVersion || ''),
656
+ oldProductVersion: String(device.productVersion || ''),
657
+ currentVersion: '',
658
+ currentProductVersion: '',
659
+ state: 'queued',
660
+ method: '',
661
+ commandId: '',
662
+ dispatchedAt: '',
663
+ deadlineAt: '',
664
+ dispatchedSessionId: '',
665
+ dispatchedPid: 0,
666
+ candidateSessionId: '',
667
+ candidatePid: 0,
668
+ candidateConnectedAt: '',
669
+ candidateProductVersion: '',
670
+ candidateAgentVersion: '',
671
+ candidateSince: '',
672
+ stabilityDeadlineAt: '',
673
+ completedAt: '',
674
+ error: ''
675
+ }))
676
+ };
677
+ touch();
678
+ if (run.targets.length === 0) {
679
+ if (run.needsHubRestart) {
680
+ if (!requestRestart()) {
681
+ return { ok: false, error: run.error, ...getStatus() };
682
+ }
683
+ } else {
752
684
  run.state = 'clients-updated';
753
685
  touch();
754
686
  }
755
687
  return { ok: true, ...getStatus() };
756
- }
757
- run.state = 'waiting-for-clients';
758
- if (!dispatchQueuedTargets()) {
759
- return { ok: false, error: run.error, ...getStatus() };
760
- }
761
- runTimer = setInterval(verifyTargets, 1000);
688
+ }
689
+ run.state = 'waiting-for-clients';
690
+ if (!dispatchQueuedTargets()) {
691
+ return { ok: false, error: run.error, ...getStatus() };
692
+ }
693
+ runTimer = setInterval(verifyTargets, 1000);
762
694
  runTimer.unref?.();
763
695
  verifyTargets();
764
696
  return { ok: true, ...getStatus() };
765
- };
766
-
767
- const handleRemoteEvent = (type, event) => {
768
- if (!run || run.state !== 'waiting-for-clients') return;
769
- if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
770
- verifyTargets();
771
- return;
772
- }
773
- if (type !== 'RemoteCommandResult') return;
697
+ };
698
+
699
+ const handleRemoteEvent = (type, event) => {
700
+ if (!run || run.state !== 'waiting-for-clients') return;
701
+ if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
702
+ verifyTargets();
703
+ return;
704
+ }
705
+ if (type !== 'RemoteCommandResult') return;
774
706
  const deviceId = String(event?.device?.deviceId || '');
775
707
  const commandId = String(event?.commandId || '');
776
708
  const target = run.targets.find(item => item.deviceId === deviceId && item.commandId === commandId);
777
- if (!target
778
- || target.state !== 'waiting'
779
- || !['dedicated', 'legacy-command-run'].includes(target.method)) return;
709
+ if (!target
710
+ || target.state !== 'waiting'
711
+ || !['dedicated', 'legacy-command-run'].includes(target.method)) return;
780
712
  const result = event?.result;
781
713
  if (event?.error || result?.ok === false || result?.status === 'failed' || result?.status === 'rejected') {
782
714
  target.state = 'failed';
@@ -794,11 +726,11 @@ export function createLiveDeskUpdateManager({
794
726
 
795
727
  return {
796
728
  checkLatest,
797
- getStatus,
798
- startUpdate,
799
- handleRemoteEvent,
800
- reconcileHubRestartResult,
801
- close() {
729
+ getStatus,
730
+ startUpdate,
731
+ handleRemoteEvent,
732
+ reconcileHubRestartResult,
733
+ close() {
802
734
  if (checkTimer) clearInterval(checkTimer);
803
735
  if (runTimer) clearInterval(runTimer);
804
736
  checkTimer = null;