@yeaft/webchat-agent 1.0.341 → 1.0.342
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +44 -22
- package/connection/upgrade.js +42 -21
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +17 -10
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/upgrade-command.js +181 -33
- package/windows-upgrade-bootstrap.js +69 -0
- package/windows-upgrade-runner.js +166 -39
|
Binary file
|
package/package.json
CHANGED
package/upgrade-command.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
3
|
import { delimiter, dirname, join, win32 } from 'node:path';
|
|
3
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
5
|
|
|
5
6
|
export const DEFAULT_UPGRADE_REGISTRY = 'https://pkg.yeaft.com/';
|
|
6
7
|
|
|
8
|
+
const WINDOWS_UPGRADE_LOCK_NAME = 'active.lock';
|
|
9
|
+
|
|
7
10
|
const ONLINE_METADATA_FLAGS = [
|
|
8
11
|
'--prefer-online',
|
|
9
12
|
'--prefer-offline=false',
|
|
@@ -71,13 +74,73 @@ export function buildUpgradeInstallCommand(packageSpec, options) {
|
|
|
71
74
|
return ['npm', ...buildUpgradeInstallArgs(packageSpec, options)].join(' ');
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
/**
|
|
75
|
-
export function
|
|
77
|
+
/** Allocate one isolated runtime directory and atomically lock the instance. */
|
|
78
|
+
export function createWindowsUpgradeRun(upgradeRoot, {
|
|
79
|
+
runId = randomUUID(),
|
|
80
|
+
parentPid = process.pid,
|
|
81
|
+
} = {}) {
|
|
82
|
+
if (!/^[A-Za-z0-9._-]+$/u.test(runId)) throw new TypeError('runId contains invalid path characters');
|
|
83
|
+
if (!Number.isInteger(parentPid) || parentPid <= 0) throw new TypeError('parentPid must be a positive integer');
|
|
84
|
+
const lockPath = join(upgradeRoot, WINDOWS_UPGRADE_LOCK_NAME);
|
|
85
|
+
mkdirSync(upgradeRoot, { recursive: true });
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
mkdirSync(lockPath);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err?.code !== 'EEXIST') throw err;
|
|
91
|
+
let owner = null;
|
|
92
|
+
try { owner = JSON.parse(readFileSync(join(lockPath, 'owner.json'), 'utf8')); } catch {}
|
|
93
|
+
const detail = /^[A-Za-z0-9._-]+$/u.test(owner?.runId || '') ? ` (run ${owner.runId})` : '';
|
|
94
|
+
throw new Error(`A Windows upgrade lock already exists for this instance${detail} at ${lockPath}; remove it only after confirming no upgrade is running`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const runDir = join(upgradeRoot, 'runs', runId);
|
|
98
|
+
try {
|
|
99
|
+
writeFileSync(join(lockPath, 'owner.json'), JSON.stringify({ runId, parentPid, createdAt: Date.now() }));
|
|
100
|
+
mkdirSync(dirname(runDir), { recursive: true });
|
|
101
|
+
mkdirSync(runDir);
|
|
102
|
+
return {
|
|
103
|
+
runId,
|
|
104
|
+
runDir,
|
|
105
|
+
lockPath,
|
|
106
|
+
bootstrapPath: join(runDir, 'windows-upgrade-bootstrap.js'),
|
|
107
|
+
runnerPath: join(runDir, 'windows-upgrade-runner.js'),
|
|
108
|
+
commandPath: join(runDir, 'upgrade-command.js'),
|
|
109
|
+
payloadPath: join(runDir, 'payload.json'),
|
|
110
|
+
handoffPath: join(runDir, 'started'),
|
|
111
|
+
authorizePath: join(runDir, 'authorized'),
|
|
112
|
+
cancelPath: join(runDir, 'cancelled'),
|
|
113
|
+
};
|
|
114
|
+
} catch (err) {
|
|
115
|
+
try { rmSync(lockPath, { recursive: true, force: true }); } catch {}
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Release the instance lock only when it still belongs to this run. */
|
|
121
|
+
export function releaseWindowsUpgradeLock(lockPath, runId) {
|
|
122
|
+
if (!lockPath || !runId) return false;
|
|
123
|
+
let owner;
|
|
124
|
+
try {
|
|
125
|
+
owner = JSON.parse(readFileSync(join(lockPath, 'owner.json'), 'utf8'));
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
if (owner?.runId !== runId) return false;
|
|
130
|
+
try {
|
|
131
|
+
rmSync(lockPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
132
|
+
return true;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Build a shell-free Node invocation for the short-lived bootstrap. */
|
|
139
|
+
export function buildWindowsUpgradeInvocation({ nodePath, bootstrapPath, runnerPath, payloadPath, logPath }) {
|
|
76
140
|
return {
|
|
77
141
|
command: nodePath,
|
|
78
|
-
args: [runnerPath, payloadPath],
|
|
142
|
+
args: [bootstrapPath, runnerPath, payloadPath],
|
|
79
143
|
options: {
|
|
80
|
-
detached: true,
|
|
81
144
|
stdio: 'ignore',
|
|
82
145
|
windowsHide: true,
|
|
83
146
|
env: { ...process.env, YEAFT_UPGRADE_LOG: logPath },
|
|
@@ -85,17 +148,33 @@ export function buildWindowsUpgradeInvocation({ nodePath, runnerPath, payloadPat
|
|
|
85
148
|
};
|
|
86
149
|
}
|
|
87
150
|
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
151
|
+
/** Copy the bootstrap and updater out of the package before npm replaces it. */
|
|
152
|
+
export function prepareWindowsUpgradeRunner({
|
|
153
|
+
sourceBootstrapPath,
|
|
154
|
+
sourceRunnerPath,
|
|
155
|
+
sourceCommandPath,
|
|
156
|
+
bootstrapPath,
|
|
157
|
+
runnerPath,
|
|
158
|
+
commandPath,
|
|
159
|
+
payloadPath,
|
|
160
|
+
payload,
|
|
161
|
+
}) {
|
|
93
162
|
const runtimeDir = dirname(runnerPath);
|
|
94
163
|
const moduleManifestPath = join(runtimeDir, 'package.json');
|
|
95
164
|
mkdirSync(runtimeDir, { recursive: true });
|
|
96
|
-
for (const path of [
|
|
165
|
+
for (const path of [
|
|
166
|
+
bootstrapPath,
|
|
167
|
+
runnerPath,
|
|
168
|
+
commandPath,
|
|
169
|
+
moduleManifestPath,
|
|
170
|
+
payloadPath,
|
|
171
|
+
payload.handoffPath,
|
|
172
|
+
payload.authorizePath,
|
|
173
|
+
payload.cancelPath,
|
|
174
|
+
]) {
|
|
97
175
|
try { rmSync(path, { force: true }); } catch {}
|
|
98
176
|
}
|
|
177
|
+
copyFileSync(sourceBootstrapPath, bootstrapPath);
|
|
99
178
|
copyFileSync(sourceRunnerPath, runnerPath);
|
|
100
179
|
copyFileSync(sourceCommandPath, commandPath);
|
|
101
180
|
writeFileSync(moduleManifestPath, JSON.stringify({ type: 'module' }));
|
|
@@ -117,94 +196,163 @@ function waitForSpawn(child) {
|
|
|
117
196
|
});
|
|
118
197
|
}
|
|
119
198
|
|
|
199
|
+
function waitForExit(child, timeoutMs) {
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
if (child.exitCode != null || child.signalCode != null) {
|
|
202
|
+
resolve({ code: child.exitCode, signal: child.signalCode });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const timer = setTimeout(() => {
|
|
206
|
+
child.removeListener('error', onError);
|
|
207
|
+
child.removeListener('exit', onExit);
|
|
208
|
+
reject(new Error(`Windows upgrade bootstrap did not exit within ${timeoutMs}ms`));
|
|
209
|
+
}, timeoutMs);
|
|
210
|
+
const onError = err => {
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
child.removeListener('exit', onExit);
|
|
213
|
+
reject(err);
|
|
214
|
+
};
|
|
215
|
+
const onExit = (code, signal) => {
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
child.removeListener('error', onError);
|
|
218
|
+
resolve({ code, signal });
|
|
219
|
+
};
|
|
220
|
+
child.once('error', onError);
|
|
221
|
+
child.once('exit', onExit);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function readHandoff(handoffPath, runId) {
|
|
226
|
+
try {
|
|
227
|
+
const handoff = JSON.parse(readFileSync(handoffPath, 'utf8'));
|
|
228
|
+
if (handoff?.runId !== runId) return null;
|
|
229
|
+
if (!Number.isInteger(handoff.runnerPid) || handoff.runnerPid <= 0) return null;
|
|
230
|
+
return handoff;
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isProcessRunning(pid, probe = process.kill) {
|
|
237
|
+
try {
|
|
238
|
+
probe(pid, 0);
|
|
239
|
+
return true;
|
|
240
|
+
} catch (err) {
|
|
241
|
+
return err?.code === 'EPERM';
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
120
245
|
async function waitForUpgradeHandoff({
|
|
121
246
|
handoffPath,
|
|
247
|
+
runId,
|
|
122
248
|
child,
|
|
123
249
|
fileExists,
|
|
124
250
|
sleep,
|
|
125
251
|
timeoutMs,
|
|
126
252
|
pollIntervalMs,
|
|
127
253
|
getChildError,
|
|
254
|
+
processRunning,
|
|
128
255
|
}) {
|
|
129
256
|
const deadline = Date.now() + timeoutMs;
|
|
130
|
-
let handoffSeen = false;
|
|
131
257
|
while (Date.now() < deadline) {
|
|
132
258
|
const childError = getChildError();
|
|
133
259
|
if (childError) throw childError;
|
|
134
|
-
if (child.exitCode != null || child.signalCode != null) {
|
|
135
|
-
const status = child.exitCode != null ? `code ${child.exitCode}` : `signal ${child.signalCode}`;
|
|
136
|
-
throw new Error(`Windows upgrade launcher exited before handoff (${status})`);
|
|
137
|
-
}
|
|
138
260
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
261
|
+
const handoff = fileExists(handoffPath) ? readHandoff(handoffPath, runId) : null;
|
|
262
|
+
if (handoff && processRunning(handoff.runnerPid)) return handoff;
|
|
263
|
+
// A clean bootstrap exit only confirms the detached spawn. The runner may
|
|
264
|
+
// still be starting and must get the rest of the handoff timeout to reply.
|
|
265
|
+
if (child.signalCode != null || (child.exitCode != null && child.exitCode !== 0)) {
|
|
266
|
+
const status = child.signalCode != null ? `signal ${child.signalCode}` : `code ${child.exitCode}`;
|
|
267
|
+
throw new Error(`Windows upgrade bootstrap exited before handoff (${status})`);
|
|
146
268
|
}
|
|
147
269
|
await sleep(pollIntervalMs);
|
|
148
270
|
}
|
|
149
|
-
throw new Error(`Windows upgrade
|
|
271
|
+
throw new Error(`Windows upgrade runner did not confirm handoff within ${timeoutMs}ms`);
|
|
150
272
|
}
|
|
151
273
|
|
|
152
274
|
/**
|
|
153
|
-
* Launch
|
|
154
|
-
*
|
|
275
|
+
* Launch a short-lived bootstrap, verify the updater PID, wait for the bootstrap
|
|
276
|
+
* to exit, and verify the updater again. Only then may the caller exit or let
|
|
277
|
+
* the updater remove the PM2 app.
|
|
155
278
|
*/
|
|
156
279
|
export async function launchWindowsUpgradeScript({
|
|
280
|
+
runId,
|
|
157
281
|
nodePath,
|
|
282
|
+
bootstrapPath,
|
|
158
283
|
runnerPath,
|
|
159
284
|
payloadPath,
|
|
160
285
|
logPath,
|
|
161
286
|
handoffPath,
|
|
287
|
+
authorizePath,
|
|
288
|
+
cancelPath,
|
|
289
|
+
lockPath,
|
|
162
290
|
spawnProcess,
|
|
163
291
|
fileExists = existsSync,
|
|
164
292
|
removeFile = path => rmSync(path, { force: true }),
|
|
293
|
+
writeCancel = (path, id) => writeFileSync(path, JSON.stringify({ runId: id, cancelledAt: Date.now() })),
|
|
294
|
+
writeAuthorize = (path, id) => writeFileSync(path, JSON.stringify({ runId: id, authorizedAt: Date.now() })),
|
|
165
295
|
sleep = delay,
|
|
296
|
+
processRunning = isProcessRunning,
|
|
166
297
|
timeoutMs = 5000,
|
|
167
298
|
pollIntervalMs = 50,
|
|
168
|
-
onHandoff,
|
|
169
299
|
}) {
|
|
300
|
+
if (!runId) throw new TypeError('runId is required');
|
|
170
301
|
if (typeof spawnProcess !== 'function') throw new TypeError('spawnProcess is required');
|
|
171
302
|
if (!handoffPath) throw new TypeError('handoffPath is required');
|
|
303
|
+
if (!authorizePath) throw new TypeError('authorizePath is required');
|
|
304
|
+
if (!cancelPath) throw new TypeError('cancelPath is required');
|
|
305
|
+
if (!lockPath) throw new TypeError('lockPath is required');
|
|
172
306
|
|
|
173
|
-
const invocation = buildWindowsUpgradeInvocation({ nodePath, runnerPath, payloadPath, logPath });
|
|
307
|
+
const invocation = buildWindowsUpgradeInvocation({ nodePath, bootstrapPath, runnerPath, payloadPath, logPath });
|
|
174
308
|
let child;
|
|
175
309
|
try {
|
|
176
310
|
child = spawnProcess(invocation.command, invocation.args, invocation.options);
|
|
177
311
|
} catch (err) {
|
|
178
|
-
|
|
312
|
+
releaseWindowsUpgradeLock(lockPath, runId);
|
|
313
|
+
throw new Error(`Windows upgrade bootstrap failed: ${err.message}`, { cause: err });
|
|
179
314
|
}
|
|
180
315
|
|
|
181
316
|
let childError = null;
|
|
317
|
+
let bootstrapSpawned = false;
|
|
182
318
|
const onChildError = err => { childError = err; };
|
|
183
319
|
child.on('error', onChildError);
|
|
184
320
|
try {
|
|
185
321
|
await waitForSpawn(child);
|
|
186
|
-
|
|
322
|
+
bootstrapSpawned = true;
|
|
323
|
+
const handoff = await waitForUpgradeHandoff({
|
|
187
324
|
handoffPath,
|
|
325
|
+
runId,
|
|
188
326
|
child,
|
|
189
327
|
fileExists,
|
|
190
328
|
sleep,
|
|
191
329
|
timeoutMs,
|
|
192
330
|
pollIntervalMs,
|
|
193
331
|
getChildError: () => childError,
|
|
332
|
+
processRunning,
|
|
194
333
|
});
|
|
195
|
-
await
|
|
334
|
+
const status = await waitForExit(child, timeoutMs);
|
|
335
|
+
if (status.code !== 0) {
|
|
336
|
+
const detail = status.signal ? `signal ${status.signal}` : `code ${status.code}`;
|
|
337
|
+
throw new Error(`Windows upgrade bootstrap exited with ${detail}`);
|
|
338
|
+
}
|
|
339
|
+
if (!processRunning(handoff.runnerPid)) {
|
|
340
|
+
throw new Error('Windows upgrade runner did not survive bootstrap exit');
|
|
341
|
+
}
|
|
342
|
+
writeAuthorize(authorizePath, runId);
|
|
196
343
|
} catch (err) {
|
|
344
|
+
try { writeCancel(cancelPath, runId); } catch {}
|
|
197
345
|
try { child.kill(); } catch {}
|
|
198
346
|
try { removeFile(handoffPath); } catch {}
|
|
347
|
+
if (!bootstrapSpawned) releaseWindowsUpgradeLock(lockPath, runId);
|
|
199
348
|
if (childError === err) {
|
|
200
|
-
throw new Error(`Windows upgrade
|
|
349
|
+
throw new Error(`Windows upgrade bootstrap failed: ${err.message}`, { cause: err });
|
|
201
350
|
}
|
|
202
351
|
throw err;
|
|
203
352
|
} finally {
|
|
204
353
|
child.removeListener('error', onChildError);
|
|
205
354
|
}
|
|
206
355
|
|
|
207
|
-
child.unref();
|
|
208
356
|
return nodePath;
|
|
209
357
|
}
|
|
210
358
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { appendFileSync } from 'node:fs';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
function appendLog(logPath, message) {
|
|
8
|
+
if (!logPath) return;
|
|
9
|
+
try {
|
|
10
|
+
appendFileSync(logPath, `[Upgrade] ${message}\n`);
|
|
11
|
+
} catch {}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Spawn the real updater from a short-lived intermediate process. Once this
|
|
16
|
+
* bootstrap exits, the updater is no longer in the live process tree rooted at
|
|
17
|
+
* the PM2-managed Agent, so PM2's Windows tree kill cannot terminate it.
|
|
18
|
+
*/
|
|
19
|
+
export function spawnWindowsUpgradeRunner({
|
|
20
|
+
nodePath,
|
|
21
|
+
runnerPath,
|
|
22
|
+
payloadPath,
|
|
23
|
+
logPath,
|
|
24
|
+
}, spawnProcess = spawn) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
let child;
|
|
27
|
+
try {
|
|
28
|
+
child = spawnProcess(nodePath, [runnerPath, payloadPath], {
|
|
29
|
+
detached: true,
|
|
30
|
+
stdio: 'ignore',
|
|
31
|
+
windowsHide: true,
|
|
32
|
+
env: { ...process.env, YEAFT_UPGRADE_LOG: logPath },
|
|
33
|
+
});
|
|
34
|
+
} catch (err) {
|
|
35
|
+
reject(err);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const onError = err => {
|
|
40
|
+
child.removeListener('spawn', onSpawn);
|
|
41
|
+
reject(err);
|
|
42
|
+
};
|
|
43
|
+
const onSpawn = () => {
|
|
44
|
+
child.removeListener('error', onError);
|
|
45
|
+
child.unref();
|
|
46
|
+
resolve(child.pid);
|
|
47
|
+
};
|
|
48
|
+
child.once('error', onError);
|
|
49
|
+
child.once('spawn', onSpawn);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
54
|
+
const [runnerPath, payloadPath] = process.argv.slice(2);
|
|
55
|
+
const logPath = process.env.YEAFT_UPGRADE_LOG;
|
|
56
|
+
try {
|
|
57
|
+
if (!runnerPath || !payloadPath) throw new Error('runner path and payload path are required');
|
|
58
|
+
const runnerPid = await spawnWindowsUpgradeRunner({
|
|
59
|
+
nodePath: process.execPath,
|
|
60
|
+
runnerPath,
|
|
61
|
+
payloadPath,
|
|
62
|
+
logPath,
|
|
63
|
+
});
|
|
64
|
+
appendLog(logPath, `Bootstrap launched updater PID ${runnerPid}`);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
appendLog(logPath, `Bootstrap failed: ${err?.stack || err}`);
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -7,12 +7,15 @@ import { pathToFileURL } from 'node:url';
|
|
|
7
7
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
8
|
import {
|
|
9
9
|
buildUpgradeInstallArgs,
|
|
10
|
+
releaseWindowsUpgradeLock,
|
|
10
11
|
resolveWindowsNpmCliPath,
|
|
11
12
|
} from './upgrade-command.js';
|
|
12
13
|
|
|
13
14
|
const PID_POLL_INTERVAL_MS = 100;
|
|
14
15
|
const PID_WAIT_TIMEOUT_MS = 30_000;
|
|
16
|
+
const HANDOFF_AUTH_TIMEOUT_MS = 15_000;
|
|
15
17
|
const FILE_LOCK_RETRY_MS = [0, 250, 750, 1_500];
|
|
18
|
+
const PM2_RETRY_MS = [0, 250, 750];
|
|
16
19
|
|
|
17
20
|
function appendLog(logPath, message) {
|
|
18
21
|
try {
|
|
@@ -107,79 +110,203 @@ export async function installWindowsUpgrade({
|
|
|
107
110
|
return { exitCode: 1, attempts: FILE_LOCK_RETRY_MS.length, command, args };
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
export async function
|
|
111
|
-
if (!pm2CliPath || !
|
|
112
|
-
appendLog(logPath,
|
|
113
|
-
const
|
|
113
|
+
export async function stopPm2Service({ nodePath, pm2CliPath, pm2AppName, logPath, run = runProcess }) {
|
|
114
|
+
if (!pm2CliPath || !pm2AppName) return true;
|
|
115
|
+
appendLog(logPath, `Removing PM2 app ${pm2AppName} before install`);
|
|
116
|
+
const deleteCode = await run(nodePath, [pm2CliPath, 'delete', pm2AppName], {
|
|
114
117
|
env: process.env,
|
|
115
118
|
windowsHide: true,
|
|
116
119
|
stdio: 'ignore',
|
|
117
120
|
});
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
return deleteCode === 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function runPm2WithRetry({ nodePath, pm2CliPath, args, logPath, label, run, sleep }) {
|
|
125
|
+
for (let attempt = 0; attempt < PM2_RETRY_MS.length; attempt++) {
|
|
126
|
+
const retryDelayMs = PM2_RETRY_MS[attempt];
|
|
127
|
+
if (retryDelayMs) {
|
|
128
|
+
appendLog(logPath, `Retrying PM2 ${label} after ${retryDelayMs}ms`);
|
|
129
|
+
await sleep(retryDelayMs);
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const code = await run(nodePath, [pm2CliPath, ...args], {
|
|
133
|
+
env: process.env,
|
|
134
|
+
windowsHide: true,
|
|
135
|
+
stdio: 'ignore',
|
|
136
|
+
});
|
|
137
|
+
if (code === 0) return true;
|
|
138
|
+
appendLog(logPath, `PM2 ${label} failed with exit code ${code} (attempt ${attempt + 1})`);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
appendLog(logPath, `PM2 ${label} failed to run (attempt ${attempt + 1}): ${err?.message || err}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function startPm2Service({
|
|
147
|
+
nodePath,
|
|
148
|
+
pm2CliPath,
|
|
149
|
+
ecosystemPath,
|
|
150
|
+
logPath,
|
|
151
|
+
run = runProcess,
|
|
152
|
+
sleep = delay,
|
|
153
|
+
}) {
|
|
154
|
+
if (!pm2CliPath || !ecosystemPath) return true;
|
|
155
|
+
appendLog(logPath, 'Re-registering Agent via PM2');
|
|
156
|
+
const started = await runPm2WithRetry({
|
|
157
|
+
nodePath,
|
|
158
|
+
pm2CliPath,
|
|
159
|
+
args: ['start', ecosystemPath],
|
|
160
|
+
logPath,
|
|
161
|
+
label: 'start',
|
|
162
|
+
run,
|
|
163
|
+
sleep,
|
|
164
|
+
});
|
|
165
|
+
if (!started) return false;
|
|
166
|
+
return runPm2WithRetry({
|
|
167
|
+
nodePath,
|
|
168
|
+
pm2CliPath,
|
|
169
|
+
args: ['save'],
|
|
170
|
+
logPath,
|
|
171
|
+
label: 'save',
|
|
172
|
+
run,
|
|
173
|
+
sleep,
|
|
123
174
|
});
|
|
124
|
-
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readRunMarker(path, runId) {
|
|
178
|
+
try {
|
|
179
|
+
return JSON.parse(readFileSync(path, 'utf8'))?.runId === runId;
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function waitForHandoffAuthorization({ authorizePath, cancelPath, runId, sleep = delay }) {
|
|
186
|
+
const deadline = Date.now() + HANDOFF_AUTH_TIMEOUT_MS;
|
|
187
|
+
while (Date.now() < deadline) {
|
|
188
|
+
if (readRunMarker(cancelPath, runId)) return false;
|
|
189
|
+
if (readRunMarker(authorizePath, runId)) return true;
|
|
190
|
+
await sleep(PID_POLL_INTERVAL_MS);
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function removeTransientFiles(handoffPath, authorizePath, payloadPath, cancelPath) {
|
|
196
|
+
for (const path of [handoffPath, authorizePath, payloadPath, cancelPath]) {
|
|
197
|
+
try { rmSync(path, { force: true }); } catch {}
|
|
198
|
+
}
|
|
125
199
|
}
|
|
126
200
|
|
|
127
201
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
202
|
+
* Wait for the original Agent to exit, remove its PM2 registration, install the
|
|
203
|
+
* exact package version, and restore the selected PM2 instance. Every process
|
|
204
|
+
* invocation is shell-free.
|
|
130
205
|
*/
|
|
131
206
|
export async function runWindowsUpgrade(options, dependencies = {}) {
|
|
132
207
|
const {
|
|
208
|
+
runId,
|
|
209
|
+
lockPath,
|
|
133
210
|
parentPid,
|
|
134
211
|
packageSpec,
|
|
135
212
|
globalInstall,
|
|
136
213
|
installDir,
|
|
137
214
|
logPath,
|
|
138
215
|
handoffPath,
|
|
216
|
+
authorizePath,
|
|
217
|
+
cancelPath,
|
|
218
|
+
bootstrapPath,
|
|
139
219
|
runnerPath,
|
|
140
220
|
commandPath,
|
|
141
221
|
payloadPath,
|
|
142
222
|
nodePath,
|
|
143
223
|
npmCliPath,
|
|
144
224
|
pm2CliPath,
|
|
225
|
+
pm2AppName,
|
|
145
226
|
ecosystemPath,
|
|
146
227
|
} = options;
|
|
147
228
|
const wait = dependencies.waitForProcessExit || waitForProcessExit;
|
|
148
229
|
const install = dependencies.installWindowsUpgrade || installWindowsUpgrade;
|
|
230
|
+
const stopService = dependencies.stopPm2Service || stopPm2Service;
|
|
149
231
|
const startService = dependencies.startPm2Service || startPm2Service;
|
|
232
|
+
const authorizeHandoff = dependencies.waitForHandoffAuthorization || waitForHandoffAuthorization;
|
|
233
|
+
const releaseLock = dependencies.releaseWindowsUpgradeLock || releaseWindowsUpgradeLock;
|
|
234
|
+
const cleanupPaths = [bootstrapPath, runnerPath, commandPath];
|
|
235
|
+
const cancelled = () => cancelPath && readRunMarker(cancelPath, runId);
|
|
150
236
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
appendLog(logPath, `Started at ${new Date().toISOString()}`);
|
|
154
|
-
appendLog(logPath, `Waiting for PID ${parentPid} to exit`);
|
|
155
|
-
const exited = await wait(parentPid);
|
|
156
|
-
if (!exited) appendLog(logPath, `Timed out waiting for PID ${parentPid}; continuing with bounded npm retries`);
|
|
157
|
-
else appendLog(logPath, 'Original process exited');
|
|
237
|
+
if (!runId) throw new TypeError('runId is required');
|
|
238
|
+
if (!lockPath) throw new TypeError('lockPath is required');
|
|
158
239
|
|
|
159
|
-
let result;
|
|
160
240
|
try {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
241
|
+
mkdirSync(dirname(handoffPath), { recursive: true });
|
|
242
|
+
if (cancelled()) {
|
|
243
|
+
appendLog(logPath, 'Upgrade was cancelled before handoff; refusing to modify the installation');
|
|
244
|
+
return { exitCode: 1, restarted: false, cleanupPaths };
|
|
245
|
+
}
|
|
246
|
+
writeFileSync(handoffPath, JSON.stringify({ runId, runnerPid: process.pid, startedAt: Date.now() }));
|
|
247
|
+
appendLog(logPath, `Started run ${runId} at ${new Date().toISOString()}`);
|
|
248
|
+
const authorized = await authorizeHandoff({ authorizePath, cancelPath, runId });
|
|
249
|
+
if (!authorized) {
|
|
250
|
+
appendLog(logPath, 'Upgrade handoff was not authorized; refusing to modify the installation');
|
|
251
|
+
return { exitCode: 1, restarted: false, cleanupPaths };
|
|
252
|
+
}
|
|
253
|
+
appendLog(logPath, `Waiting for PID ${parentPid} to exit`);
|
|
254
|
+
const exited = await wait(parentPid);
|
|
255
|
+
if (!exited) {
|
|
256
|
+
appendLog(logPath, `Timed out waiting for PID ${parentPid}; refusing to modify a live installation`);
|
|
257
|
+
return { exitCode: 1, restarted: false, cleanupPaths };
|
|
258
|
+
}
|
|
259
|
+
appendLog(logPath, 'Original process exited');
|
|
260
|
+
if (cancelled()) {
|
|
261
|
+
appendLog(logPath, 'Upgrade was cancelled during handoff; refusing to modify the installation');
|
|
262
|
+
return { exitCode: 1, restarted: false, cleanupPaths };
|
|
263
|
+
}
|
|
167
264
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
265
|
+
let stopped = false;
|
|
266
|
+
try {
|
|
267
|
+
stopped = await stopService({ nodePath, pm2CliPath, pm2AppName, logPath });
|
|
268
|
+
} catch (err) {
|
|
269
|
+
appendLog(logPath, `PM2 app removal failed: ${err?.message || err}`);
|
|
270
|
+
}
|
|
271
|
+
if (!stopped) {
|
|
272
|
+
appendLog(logPath, `PM2 app ${pm2AppName} could not be removed; refusing to install`);
|
|
273
|
+
let restored = false;
|
|
274
|
+
try {
|
|
275
|
+
restored = await startService({ nodePath, pm2CliPath, ecosystemPath, logPath });
|
|
276
|
+
} catch (err) {
|
|
277
|
+
appendLog(logPath, `WARNING: PM2 recovery failed: ${err?.message || err}`);
|
|
278
|
+
}
|
|
279
|
+
if (!restored) appendLog(logPath, 'WARNING: PM2 service was not restored after removal failure');
|
|
280
|
+
return { exitCode: 1, restarted: restored, cleanupPaths };
|
|
281
|
+
}
|
|
176
282
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
283
|
+
let result;
|
|
284
|
+
try {
|
|
285
|
+
result = await install({ nodePath, npmCliPath, packageSpec, globalInstall, installDir, logPath });
|
|
286
|
+
} catch (err) {
|
|
287
|
+
appendLog(logPath, `npm install failed to start: ${err?.message || err}`);
|
|
288
|
+
result = { exitCode: 1, attempts: 0 };
|
|
289
|
+
}
|
|
290
|
+
appendLog(logPath, `npm install ${result.exitCode === 0 ? 'succeeded' : `failed with exit code ${result.exitCode}`} after ${result.attempts} attempt(s)`);
|
|
291
|
+
|
|
292
|
+
let restarted = false;
|
|
293
|
+
try {
|
|
294
|
+
restarted = await startService({ nodePath, pm2CliPath, ecosystemPath, logPath });
|
|
295
|
+
} catch (err) {
|
|
296
|
+
appendLog(logPath, `WARNING: PM2 service restart failed: ${err?.message || err}`);
|
|
297
|
+
}
|
|
298
|
+
if (!restarted) appendLog(logPath, 'WARNING: PM2 service was not restarted');
|
|
299
|
+
const exitCode = result.exitCode === 0 && restarted ? 0 : 1;
|
|
300
|
+
appendLog(logPath, `Finished at ${new Date().toISOString()} with exit code ${exitCode}`);
|
|
301
|
+
return { exitCode, restarted, cleanupPaths };
|
|
302
|
+
} finally {
|
|
303
|
+
removeTransientFiles(handoffPath, authorizePath, payloadPath, cancelPath);
|
|
304
|
+
if (!releaseLock(lockPath, runId)) {
|
|
305
|
+
const message = `Upgrade lock was not released for run ${runId}`;
|
|
306
|
+
appendLog(logPath, message);
|
|
307
|
+
throw new Error(message);
|
|
308
|
+
}
|
|
181
309
|
}
|
|
182
|
-
return { exitCode: result.exitCode, restarted, cleanupPaths: [runnerPath, commandPath] };
|
|
183
310
|
}
|
|
184
311
|
|
|
185
312
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|