grix-connector 4.0.2 → 4.2.0

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.
Files changed (63) hide show
  1. package/dist/adapter/acp/acp-adapter.js +3 -3
  2. package/dist/adapter/acp/session-activity.js +1 -1
  3. package/dist/adapter/claude/claude-bridge-server.js +1 -0
  4. package/dist/adapter/claude/claude-tools.js +1 -0
  5. package/dist/adapter/claude/claude-worker-client.js +1 -0
  6. package/dist/adapter/claude/mcp-http-launcher.js +2 -0
  7. package/dist/adapter/claude/result-timeout.js +1 -0
  8. package/dist/adapter/deepseek/deepseek-adapter.js +6 -0
  9. package/dist/adapter/deepseek/index.js +1 -0
  10. package/dist/adapter/qwen/index.js +1 -0
  11. package/dist/adapter/qwen/qwen-adapter.js +4 -0
  12. package/dist/aibot/client.js +1 -0
  13. package/dist/aibot/index.js +1 -0
  14. package/dist/aibot/types.js +0 -0
  15. package/dist/assets/dsh-bridge/grix-dsh-bridge-4.2.0.tgz +0 -0
  16. package/dist/assets/dsh-bridge/manifest.json +5 -5
  17. package/dist/audit/adapters/agy/agy-audit-adapter.js +1 -1
  18. package/dist/audit/adapters/agy/agy-gen-metadata.js +1 -0
  19. package/dist/bridge/bridge.js +7 -7
  20. package/dist/bridge/lifecycle-barrier.js +1 -1
  21. package/dist/core/access/allowlist-gate.js +1 -0
  22. package/dist/core/access/allowlist-store.js +1 -0
  23. package/dist/core/access/index.js +1 -0
  24. package/dist/core/aibot/connection-manager.js +1 -1
  25. package/dist/core/config/agent-config-repository.js +2 -0
  26. package/dist/core/file-ops/handler.js +1 -0
  27. package/dist/core/file-ops/list-files.js +1 -0
  28. package/dist/core/file-ops/types.js +0 -0
  29. package/dist/core/installer/npm-registry.js +4 -4
  30. package/dist/core/runtime/health.js +1 -1
  31. package/dist/core/skill-sync/skill-syncer.js +1 -1
  32. package/dist/core/upgrade/npm-upgrader.js +2 -2
  33. package/dist/core/upgrade/upgrade-checker.js +1 -1
  34. package/dist/default-skills/grix-agent-dispatch/SKILL.md +9 -9
  35. package/dist/grix.js +7 -7
  36. package/dist/log.js +3 -0
  37. package/dist/main.js +31 -0
  38. package/dist/manager.js +1 -2
  39. package/dist/mcp/stream-http/config.js +1 -0
  40. package/dist/mcp/stream-http/connection-binding.js +1 -0
  41. package/dist/mcp/stream-http/event-tool-executor.js +1 -0
  42. package/dist/mcp/stream-http/gateway.js +1 -0
  43. package/dist/mcp/stream-http/index.js +1 -0
  44. package/dist/mcp/stream-http/security.js +1 -0
  45. package/dist/mcp/stream-http/session-manager.js +1 -0
  46. package/dist/mcp/stream-http/tool-executor.js +1 -0
  47. package/dist/mcp/stream-http/tool-registry.js +1 -0
  48. package/dist/mcp/stream-http/tool-schemas.js +1 -0
  49. package/dist/service/platform-adapter.js +25 -24
  50. package/dist/service/process-control.js +1 -1
  51. package/dist/service/service-manager.js +3 -3
  52. package/dist/service/service-paths.js +2 -2
  53. package/dist/service/windows-wrapper-migration.js +1 -0
  54. package/dist/session/index.js +1 -0
  55. package/dist/session/manager.js +1 -0
  56. package/dist/transport/index.js +1 -0
  57. package/dist/transport/json-rpc.js +3 -0
  58. package/openclaw-plugin/audit-recorder-worker.js +496 -54
  59. package/package.json +2 -1
  60. package/scripts/install-guardian.mjs +35 -19
  61. package/scripts/upgrade-guardian.mjs +1345 -0
  62. package/scripts/upgrade-guardian.sh +100 -75
  63. package/dist/assets/dsh-bridge/grix-dsh-bridge-4.0.2.tgz +0 -0
@@ -0,0 +1,1345 @@
1
+ #!/usr/bin/env node
2
+ // Cross-platform Node guardian used by the Windows service wrapper.
3
+ // It is copied outside the npm package to <GRIX_HOME>/bin so an upgrade can
4
+ // replace the active global package without replacing the running guardian.
5
+
6
+ import {
7
+ copyFileSync,
8
+ cpSync,
9
+ existsSync,
10
+ mkdirSync,
11
+ readFileSync,
12
+ renameSync,
13
+ rmSync,
14
+ writeFileSync,
15
+ } from 'node:fs';
16
+ import { execFileSync, spawn } from 'node:child_process';
17
+ import { createHash } from 'node:crypto';
18
+ import { createServer } from 'node:net';
19
+ import { homedir, uptime } from 'node:os';
20
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const PACKAGE_NAME = 'grix-connector';
24
+ const PACKAGE_BACKUP_SUFFIX = '.grix-upgrade-backup';
25
+ const PACKAGE_FAILED_SUFFIX = '.grix-upgrade-failed';
26
+ const RUNTIME_BACKUP_SUFFIX = '.grix-upgrade-backup';
27
+ const RUNTIME_NEXT_SUFFIX = '.grix-upgrade-next';
28
+ const NPM_PREFIX_UPGRADE_LOCK_FILE = '.grix-connector-upgrade.lock';
29
+ const HEALTH_TIMEOUT_MS = 90_000;
30
+ /** adopt-wrapper 等待旧 daemon 优雅退出的上限;超时也要把 wrapper 拉回来。 */
31
+ const ADOPT_WRAPPER_DAEMON_EXIT_TIMEOUT_MS = 120_000;
32
+ const HEALTH_POLL_MS = 2_000;
33
+ const MONITOR_IDENTITY_GRACE_MS = 10_000;
34
+ const TRANSACTION_PHASES = new Set([
35
+ 'preparing',
36
+ 'staged',
37
+ 'handoff_ready',
38
+ 'activating',
39
+ 'verifying',
40
+ 'healthy',
41
+ 'rolling_back',
42
+ 'rolled_back',
43
+ 'finalizing',
44
+ 'failed',
45
+ ]);
46
+ const RENAME_RETRY_DELAYS_MS = [250, 750, 1_500, 3_000, 5_000];
47
+
48
+ function pathsFor(rootDir) {
49
+ return {
50
+ pending: join(rootDir, 'data', 'upgrade-pending.json'),
51
+ pause: join(rootDir, 'data', 'upgrade-wrapper.pause.json'),
52
+ rollbackIntent: join(rootDir, 'data', 'upgrade-rollback.intent.json'),
53
+ lock: join(rootDir, 'upgrade-guardian.lock'),
54
+ log: join(rootDir, 'log', 'upgrade.log'),
55
+ healthPort: join(rootDir, 'data', 'health-port'),
56
+ daemonLock: join(rootDir, 'daemon.lock.json'),
57
+ daemonStatus: join(rootDir, 'daemon-status.json'),
58
+ wrapperChild: join(rootDir, 'data', 'upgrade-wrapper-child.json'),
59
+ monitorHeartbeat: join(rootDir, 'data', 'upgrade-monitor-heartbeat.json'),
60
+ installedGuardian: join(rootDir, 'bin', 'upgrade-guardian.mjs'),
61
+ fallbackGuardian: join(rootDir, 'bin', 'upgrade-guardian.fallback.mjs'),
62
+ };
63
+ }
64
+
65
+ function resolveManagedRuntimeDir(rootDir, env = process.env, userHome = homedir()) {
66
+ const localAppData = String(env.LOCALAPPDATA ?? '').trim();
67
+ const base = localAppData && isAbsolute(localAppData)
68
+ ? localAppData
69
+ : join(userHome, 'AppData', 'Local');
70
+ const serviceId = `GrixConnectorDaemon-${createHash('sha1')
71
+ .update(resolve(rootDir).trim() || 'default')
72
+ .digest('hex')
73
+ .slice(0, 12)}`;
74
+ return join(base, 'grix-connector', 'runtimes', serviceId);
75
+ }
76
+
77
+ function resolveTransactionRuntimeDir(rootDir, pending, deps = {}) {
78
+ if (deps.runtimeDir) return resolve(deps.runtimeDir);
79
+ if (!pending?.runtime_dir) return resolveManagedRuntimeDir(rootDir, deps.env, deps.homeDir);
80
+ const candidate = resolve(pending.runtime_dir);
81
+ const expectedServiceId = `GrixConnectorDaemon-${createHash('sha1')
82
+ .update(resolve(rootDir).trim() || 'default')
83
+ .digest('hex')
84
+ .slice(0, 12)}`;
85
+ if (
86
+ !isAbsolute(candidate)
87
+ || basename(candidate) !== expectedServiceId
88
+ || basename(dirname(candidate)) !== 'runtimes'
89
+ || basename(dirname(dirname(candidate))) !== 'grix-connector'
90
+ ) throw new Error(`unsafe recorded managed runtime path: ${candidate}`);
91
+ return candidate;
92
+ }
93
+
94
+ function sleep(ms) {
95
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
96
+ }
97
+
98
+ function readJson(file) {
99
+ // Windows PowerShell 5.1's default UTF-8 encoding emits a BOM. New launchers
100
+ // write without one, but accept records left by an older wrapper so rollback
101
+ // can still identify and terminate the exact child it launched.
102
+ return JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
103
+ }
104
+
105
+ function writeJsonAtomic(file, value) {
106
+ mkdirSync(dirname(file), { recursive: true });
107
+ const tmp = `${file}.tmp-${process.pid}`;
108
+ writeFileSync(tmp, JSON.stringify(value), 'utf8');
109
+ renameSync(tmp, file);
110
+ }
111
+
112
+ function readRollbackIntent(paths, transactionId) {
113
+ try {
114
+ const intent = readJson(paths.rollbackIntent);
115
+ return intent?.transaction_id === transactionId ? intent : null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ function clearRollbackIntent(paths, transactionId) {
122
+ if (!readRollbackIntent(paths, transactionId)) return;
123
+ try { rmSync(paths.rollbackIntent, { force: true }); } catch { /* retry on the next guardian pass */ }
124
+ }
125
+
126
+ function appendLog(file, message) {
127
+ mkdirSync(dirname(file), { recursive: true });
128
+ writeFileSync(file, `[${new Date().toISOString()}] guardian-js: ${message}\n`, { flag: 'a' });
129
+ }
130
+
131
+ function readPackageMetadata(packageDir) {
132
+ return readJson(join(packageDir, 'package.json'));
133
+ }
134
+
135
+ function packageVersion(packageDir) {
136
+ try {
137
+ const metadata = readPackageMetadata(packageDir);
138
+ if (metadata.name !== PACKAGE_NAME || typeof metadata.version !== 'string') return null;
139
+ return metadata.version.trim().replace(/^v(?=\d)/, '');
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ function validatePackage(packageDir, expectedVersion) {
146
+ const metadata = readPackageMetadata(packageDir);
147
+ const version = typeof metadata.version === 'string'
148
+ ? metadata.version.trim().replace(/^v(?=\d)/, '')
149
+ : '';
150
+ if (metadata.name !== PACKAGE_NAME || version !== expectedVersion) {
151
+ throw new Error(`package mismatch at ${packageDir}: expected ${PACKAGE_NAME}@${expectedVersion}`);
152
+ }
153
+ const bins = typeof metadata.bin === 'string'
154
+ ? [[PACKAGE_NAME, metadata.bin]]
155
+ : Object.entries(metadata.bin ?? {}).filter(([, value]) => typeof value === 'string');
156
+ if (bins.length === 0) throw new Error(`package has no executable bin: ${packageDir}`);
157
+ for (const [, entry] of bins) {
158
+ if (!existsSync(join(packageDir, entry))) throw new Error(`package entrypoint is missing: ${entry}`);
159
+ }
160
+ }
161
+
162
+ function isPathInside(parent, child) {
163
+ const rel = relative(resolve(parent), resolve(child));
164
+ return rel !== '' && !rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel);
165
+ }
166
+
167
+ /**
168
+ * 事务自述的激活策略。guardian 按它分支而不看运行平台:
169
+ * `wrapper-runtime` 是 Windows 现状(wrapper 承接、切 per-root runtime);
170
+ * `service-start` 是 macOS——切包后运行目标 CLI `start` 刷新受管 runtime 并重启
171
+ * 服务,不做 runtime 目录 rename,也不需要杀进程(`start` 自己完成服务重启)。
172
+ * 历史事务没有该字段,视为 `wrapper-runtime`。
173
+ */
174
+ export function transactionStrategy(pending) {
175
+ return pending?.activation_strategy === 'service-start' ? 'service-start' : 'wrapper-runtime';
176
+ }
177
+
178
+ function defaultRunServiceStart(cliPath) {
179
+ execFileSync(process.execPath, [cliPath, 'start'], {
180
+ timeout: 180_000,
181
+ windowsHide: true,
182
+ stdio: 'ignore',
183
+ });
184
+ }
185
+
186
+ export function validateTransaction(pending) {
187
+ if (!pending || typeof pending !== 'object') throw new Error('pending transaction is missing');
188
+ if (typeof pending.phase !== 'string' || !TRANSACTION_PHASES.has(pending.phase)) {
189
+ throw new Error(`unknown upgrade transaction phase: ${String(pending.phase)}`);
190
+ }
191
+ if (
192
+ pending.activation_strategy !== undefined
193
+ && pending.activation_strategy !== 'wrapper-runtime'
194
+ && pending.activation_strategy !== 'service-start'
195
+ ) {
196
+ throw new Error(`unknown upgrade activation strategy: ${String(pending.activation_strategy)}`);
197
+ }
198
+ for (const key of ['transaction_id', 'from_version', 'target_version', 'npm_prefix', 'npm_lock_file', 'package_dir', 'backup_dir', 'staged_package_dir', 'staging_prefix']) {
199
+ if (typeof pending[key] !== 'string' || pending[key].trim() === '') {
200
+ throw new Error(`pending transaction is missing ${key}`);
201
+ }
202
+ }
203
+ const packageFromVersion = typeof pending.package_from_version === 'string' && pending.package_from_version.trim()
204
+ ? pending.package_from_version.trim()
205
+ : pending.from_version;
206
+ if (
207
+ pending.package_activation_mode !== undefined
208
+ && pending.package_activation_mode !== 'switched'
209
+ && pending.package_activation_mode !== 'unchanged'
210
+ ) throw new Error(`unknown package activation mode: ${String(pending.package_activation_mode)}`);
211
+ const packageDir = resolve(pending.package_dir);
212
+ const backupDir = resolve(pending.backup_dir);
213
+ const stagedPackageDir = resolve(pending.staged_package_dir);
214
+ const stagingPrefix = resolve(pending.staging_prefix);
215
+ const npmPrefix = resolve(pending.npm_prefix);
216
+ const npmLockFile = resolve(pending.npm_lock_file);
217
+ if (
218
+ dirname(dirname(packageDir)) !== npmPrefix
219
+ || basename(dirname(packageDir)) !== 'node_modules'
220
+ || basename(packageDir) !== PACKAGE_NAME
221
+ ) {
222
+ throw new Error(`unsafe active package path: ${packageDir}`);
223
+ }
224
+ if (backupDir !== `${packageDir}${PACKAGE_BACKUP_SUFFIX}`) {
225
+ throw new Error(`unsafe backup package path: ${backupDir}`);
226
+ }
227
+ if (npmLockFile !== join(npmPrefix, NPM_PREFIX_UPGRADE_LOCK_FILE)) {
228
+ throw new Error(`unsafe npm prefix lock path: ${npmLockFile}`);
229
+ }
230
+ if (
231
+ dirname(stagingPrefix) !== npmPrefix
232
+ || !basename(stagingPrefix).startsWith('.grix-upgrade-stage-')
233
+ || !isPathInside(stagingPrefix, stagedPackageDir)
234
+ || basename(dirname(stagedPackageDir)) !== 'node_modules'
235
+ || basename(stagedPackageDir) !== PACKAGE_NAME
236
+ ) {
237
+ throw new Error(`unsafe staged package path: ${stagedPackageDir}`);
238
+ }
239
+ return {
240
+ ...pending,
241
+ package_from_version: packageFromVersion,
242
+ npm_prefix: npmPrefix,
243
+ npm_lock_file: npmLockFile,
244
+ package_dir: packageDir,
245
+ backup_dir: backupDir,
246
+ staged_package_dir: stagedPackageDir,
247
+ staging_prefix: stagingPrefix,
248
+ };
249
+ }
250
+
251
+ function validateNpmPrefixLock(pending, pendingFile) {
252
+ let lock;
253
+ try {
254
+ lock = existsSync(join(pending.npm_lock_file, 'owner.json'))
255
+ ? readJson(join(pending.npm_lock_file, 'owner.json'))
256
+ : readJson(pending.npm_lock_file);
257
+ } catch { throw new Error('shared npm upgrade lock is missing or unreadable'); }
258
+ if (
259
+ lock?.transaction_id !== pending.transaction_id
260
+ || resolve(String(lock?.pending_file ?? '')) !== resolve(pendingFile)
261
+ ) {
262
+ throw new Error('shared npm upgrade lock belongs to another transaction');
263
+ }
264
+ }
265
+
266
+ function validateRuntimeTransaction(pending, runtimeDir) {
267
+ const resolvedRuntime = resolve(runtimeDir);
268
+ const runtimeBackup = `${resolvedRuntime}${RUNTIME_BACKUP_SUFFIX}`;
269
+ const runtimeNext = `${resolvedRuntime}${RUNTIME_NEXT_SUFFIX}`;
270
+ if (pending.runtime_dir && resolve(pending.runtime_dir) !== resolvedRuntime) {
271
+ throw new Error(`unsafe managed runtime path: ${pending.runtime_dir}`);
272
+ }
273
+ if (pending.runtime_backup_dir && resolve(pending.runtime_backup_dir) !== runtimeBackup) {
274
+ throw new Error(`unsafe managed runtime backup path: ${pending.runtime_backup_dir}`);
275
+ }
276
+ if (pending.runtime_next_dir && resolve(pending.runtime_next_dir) !== runtimeNext) {
277
+ throw new Error(`unsafe managed runtime staging path: ${pending.runtime_next_dir}`);
278
+ }
279
+ return { runtimeDir: resolvedRuntime, runtimeBackup, runtimeNext };
280
+ }
281
+
282
+ export function isTransientFsError(error) {
283
+ const code = error && typeof error === 'object' ? error.code : undefined;
284
+ return code === 'EBUSY' || code === 'EPERM' || code === 'EACCES' || code === 'ENOTEMPTY';
285
+ }
286
+
287
+ export async function renameWithRetry(source, destination, deps = {}) {
288
+ const rename = deps.rename ?? renameSync;
289
+ const wait = deps.sleep ?? sleep;
290
+ let lastError;
291
+ for (let attempt = 0; attempt <= RENAME_RETRY_DELAYS_MS.length; attempt++) {
292
+ try {
293
+ rename(source, destination);
294
+ return;
295
+ } catch (error) {
296
+ lastError = error;
297
+ if (!isTransientFsError(error) || attempt >= RENAME_RETRY_DELAYS_MS.length) throw error;
298
+ await wait(RENAME_RETRY_DELAYS_MS[attempt]);
299
+ }
300
+ }
301
+ throw lastError;
302
+ }
303
+
304
+ function guardianLockEndpoint(rootDir, platform = process.platform) {
305
+ const digest = createHash('sha256').update(resolve(rootDir)).digest('hex');
306
+ if (platform === 'win32') return `\\\\.\\pipe\\grix-upgrade-${digest.slice(0, 24)}`;
307
+ return { host: '127.0.0.1', port: 35_000 + (Number.parseInt(digest.slice(0, 8), 16) % 20_000) };
308
+ }
309
+
310
+ export async function acquireGuardianProcessLock(rootDir, deps = {}) {
311
+ const lockFile = pathsFor(rootDir).lock;
312
+ const server = (deps.createServer ?? createServer)();
313
+ const endpoint = deps.lockEndpoint ?? guardianLockEndpoint(rootDir, deps.platform);
314
+ const acquired = await new Promise((resolveAcquire, reject) => {
315
+ const onError = (error) => {
316
+ if (error?.code === 'EADDRINUSE' || error?.code === 'EACCES') resolveAcquire(false);
317
+ else reject(error);
318
+ };
319
+ server.once('error', onError);
320
+ server.once('listening', () => {
321
+ server.removeListener('error', onError);
322
+ server.on('error', () => { /* the process-lifetime lock remains fail-closed */ });
323
+ resolveAcquire(true);
324
+ });
325
+ server.listen(endpoint);
326
+ });
327
+ if (!acquired) return null;
328
+ server.unref?.();
329
+ const token = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
330
+ try {
331
+ writeJsonAtomic(lockFile, {
332
+ protocol: 'os-lifetime-v1',
333
+ token,
334
+ pid: process.pid,
335
+ created_at: Date.now(),
336
+ command_line: readProcessCommandLine(process.pid, deps),
337
+ });
338
+ } catch (error) {
339
+ await new Promise((resolveClose) => server.close(() => resolveClose()));
340
+ throw error;
341
+ }
342
+ return { server, lockFile, token };
343
+ }
344
+
345
+ export async function releaseGuardianProcessLock(handle) {
346
+ if (!handle) return;
347
+ try {
348
+ const lock = readJson(handle.lockFile);
349
+ if (lock?.protocol === 'os-lifetime-v1' && lock?.token === handle.token) {
350
+ rmSync(handle.lockFile, { force: true });
351
+ }
352
+ } catch { /* best effort */ }
353
+ await new Promise((resolveClose) => {
354
+ try { handle.server.close(() => resolveClose()); } catch { resolveClose(); }
355
+ });
356
+ }
357
+
358
+ function isPidAlive(pid) {
359
+ if (!Number.isInteger(pid) || pid <= 0) return false;
360
+ try { process.kill(pid, 0); return true; } catch { return false; }
361
+ }
362
+
363
+ function readProcessCommandLine(pid, deps = {}) {
364
+ if (deps.getProcessCommandLine) return deps.getProcessCommandLine(pid);
365
+ if (process.platform !== 'win32' || !Number.isInteger(pid) || pid <= 0) return null;
366
+ try {
367
+ const output = execFileSync(
368
+ powershellPath(),
369
+ [
370
+ '-NoProfile',
371
+ '-NonInteractive',
372
+ '-WindowStyle',
373
+ 'Hidden',
374
+ '-Command',
375
+ `(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CommandLine`,
376
+ ],
377
+ { encoding: 'utf8', timeout: 3_000, windowsHide: true },
378
+ ).trim();
379
+ return output || null;
380
+ } catch {
381
+ return null;
382
+ }
383
+ }
384
+
385
+ function originalProcessAlive(pending, deps = {}) {
386
+ const recordedBootTime = Number(pending.old_boot_time);
387
+ const currentBootTime = currentBootTimestamp(deps);
388
+ // A process cannot survive an OS reboot. This proof lets recovery proceed
389
+ // when the old PID has been reused and CIM command-line inspection is
390
+ // unavailable, without authorizing a kill of the unrelated new process.
391
+ if (
392
+ Number.isFinite(recordedBootTime)
393
+ && Number.isFinite(currentBootTime)
394
+ && Math.abs(currentBootTime - recordedBootTime) > 60_000
395
+ ) return false;
396
+ const alive = deps.isPidAlive ?? isPidAlive;
397
+ if (!alive(pending.old_pid)) return false;
398
+ const recorded = typeof pending.old_command_line === 'string' && pending.old_command_line
399
+ ? pending.old_command_line
400
+ : null;
401
+ if (!recorded) return true;
402
+ const current = readProcessCommandLine(pending.old_pid, deps);
403
+ return current === null || current === recorded;
404
+ }
405
+
406
+ async function waitForOriginalProcessExit(pending, timeoutMs = 15_000, deps = {}) {
407
+ const wait = deps.sleep ?? sleep;
408
+ const now = deps.now ?? Date.now;
409
+ const deadline = now() + timeoutMs;
410
+ while (originalProcessAlive(pending, deps) && now() < deadline) await wait(250);
411
+ return !originalProcessAlive(pending, deps);
412
+ }
413
+
414
+ function monitorIdentityAlive(pending, paths, deps = {}) {
415
+ const alive = deps.isPidAlive ?? isPidAlive;
416
+ const now = deps.now ?? Date.now;
417
+ if (!Number.isInteger(pending.guardian_pid) || !alive(pending.guardian_pid)) return false;
418
+ try {
419
+ const heartbeat = readJson(paths.monitorHeartbeat);
420
+ if (
421
+ heartbeat?.transaction_id === pending.transaction_id
422
+ && heartbeat?.pid === pending.guardian_pid
423
+ && Number.isFinite(heartbeat?.updated_at)
424
+ && now() - heartbeat.updated_at <= MONITOR_IDENTITY_GRACE_MS
425
+ ) return true;
426
+ } catch { /* monitor may still be starting */ }
427
+ const startedAt = Date.parse(pending.guardian_started_at ?? '');
428
+ return Number.isFinite(startedAt) && now() - startedAt <= MONITOR_IDENTITY_GRACE_MS;
429
+ }
430
+
431
+ async function waitForPidExit(pid, timeoutMs = 15_000, deps = {}) {
432
+ const alive = deps.isPidAlive ?? isPidAlive;
433
+ const wait = deps.sleep ?? sleep;
434
+ const now = deps.now ?? Date.now;
435
+ const deadline = now() + timeoutMs;
436
+ while (alive(pid) && now() < deadline) await wait(250);
437
+ return !alive(pid);
438
+ }
439
+
440
+ function updatePending(pendingFile, patch, expected = {}) {
441
+ const current = readJson(pendingFile);
442
+ if (expected.transactionId && current.transaction_id !== expected.transactionId) {
443
+ throw new Error('upgrade transaction ownership changed');
444
+ }
445
+ if (expected.phase && current.phase !== expected.phase) {
446
+ throw new Error(`upgrade transaction phase changed to ${String(current.phase)}`);
447
+ }
448
+ if (expected.guardianPid !== undefined && current.guardian_pid !== expected.guardianPid) {
449
+ throw new Error('upgrade verification monitor ownership changed');
450
+ }
451
+ const next = { ...current, ...patch };
452
+ writeJsonAtomic(pendingFile, next);
453
+ return next;
454
+ }
455
+
456
+ async function switchPackage(pending, deps = {}) {
457
+ const rename = (source, destination) => renameWithRetry(source, destination, deps);
458
+ const isValid = (directory, version) => {
459
+ try { validatePackage(directory, version); return true; } catch { return false; }
460
+ };
461
+ const sourceVersion = pending.package_from_version ?? pending.from_version;
462
+ if (isValid(pending.package_dir, pending.target_version)) {
463
+ if (sourceVersion === pending.target_version) {
464
+ // A completed legacy/root transaction may have left the fixed backup
465
+ // behind. This root did not switch the shared package and does not depend
466
+ // on that backup; terminal cleanup will remove it while holding the lock.
467
+ updatePending(deps.pendingFile, { package_activation_mode: 'unchanged' }, {
468
+ transactionId: pending.transaction_id,
469
+ phase: 'activating',
470
+ });
471
+ return 'unchanged';
472
+ }
473
+ if (!isValid(pending.backup_dir, sourceVersion)) {
474
+ throw new Error('target package is active but its global source backup is missing or invalid');
475
+ }
476
+ updatePending(deps.pendingFile, { package_activation_mode: 'switched' }, {
477
+ transactionId: pending.transaction_id,
478
+ phase: 'activating',
479
+ });
480
+ return 'switched';
481
+ }
482
+ if (!isValid(pending.staged_package_dir, pending.target_version)) {
483
+ throw new Error('staged target package is missing or invalid');
484
+ }
485
+
486
+ // Never discard the only valid source backup. Interrupted renames can leave
487
+ // a corrupt active tree next to a complete backup; restore that backup first.
488
+ if (!isValid(pending.package_dir, sourceVersion)) {
489
+ if (!isValid(pending.backup_dir, sourceVersion)) {
490
+ throw new Error('active package and source backup are both invalid');
491
+ }
492
+ const failed = `${pending.package_dir}${PACKAGE_FAILED_SUFFIX}`;
493
+ rmSync(failed, { recursive: true, force: true });
494
+ if (existsSync(pending.package_dir)) await rename(pending.package_dir, failed);
495
+ await rename(pending.backup_dir, pending.package_dir);
496
+ rmSync(failed, { recursive: true, force: true });
497
+ } else if (existsSync(pending.backup_dir)) {
498
+ // Active source has been fully validated, so a stale backup can now be
499
+ // replaced without reducing the number of runnable source copies below one.
500
+ rmSync(pending.backup_dir, { recursive: true, force: true });
501
+ }
502
+ updatePending(deps.pendingFile, { package_activation_mode: 'switched' }, {
503
+ transactionId: pending.transaction_id,
504
+ phase: 'activating',
505
+ });
506
+ await rename(pending.package_dir, pending.backup_dir);
507
+ try {
508
+ await rename(pending.staged_package_dir, pending.package_dir);
509
+ validatePackage(pending.package_dir, pending.target_version);
510
+ } catch (error) {
511
+ if (existsSync(pending.backup_dir)) {
512
+ const failed = `${pending.package_dir}${PACKAGE_FAILED_SUFFIX}`;
513
+ rmSync(failed, { recursive: true, force: true });
514
+ if (existsSync(pending.package_dir)) await rename(pending.package_dir, failed);
515
+ await rename(pending.backup_dir, pending.package_dir);
516
+ rmSync(failed, { recursive: true, force: true });
517
+ }
518
+ throw error;
519
+ }
520
+ return 'switched';
521
+ }
522
+
523
+ async function deployManagedRuntime(pending, runtimeDir, deps = {}) {
524
+ const runtimeBackup = `${runtimeDir}${RUNTIME_BACKUP_SUFFIX}`;
525
+ const runtimeNext = `${runtimeDir}${RUNTIME_NEXT_SUFFIX}`;
526
+ updatePending(deps.pendingFile, {
527
+ runtime_dir: runtimeDir,
528
+ runtime_backup_dir: runtimeBackup,
529
+ runtime_next_dir: runtimeNext,
530
+ });
531
+
532
+ const isValid = (directory, version) => {
533
+ try { validatePackage(directory, version); return true; } catch { return false; }
534
+ };
535
+ if (isValid(runtimeDir, pending.target_version) && isValid(runtimeBackup, pending.from_version)) return;
536
+
537
+ if (!isValid(runtimeDir, pending.from_version)) {
538
+ if (!isValid(runtimeBackup, pending.from_version)) {
539
+ throw new Error('active managed runtime and source backup are both invalid');
540
+ }
541
+ const failed = `${runtimeDir}${PACKAGE_FAILED_SUFFIX}`;
542
+ rmSync(failed, { recursive: true, force: true });
543
+ if (existsSync(runtimeDir)) await renameWithRetry(runtimeDir, failed, deps);
544
+ await renameWithRetry(runtimeBackup, runtimeDir, deps);
545
+ rmSync(failed, { recursive: true, force: true });
546
+ } else if (existsSync(runtimeBackup)) {
547
+ rmSync(runtimeBackup, { recursive: true, force: true });
548
+ }
549
+
550
+ if (!isValid(runtimeNext, pending.target_version)) {
551
+ // Compatibility fallback for a transaction created before runtime
552
+ // pre-staging existed. New transactions finish this expensive copy while
553
+ // the old daemon is still online.
554
+ rmSync(runtimeNext, { recursive: true, force: true });
555
+ mkdirSync(runtimeNext, { recursive: true });
556
+ cpSync(join(pending.package_dir, 'dist'), join(runtimeNext, 'dist'), { recursive: true, force: true });
557
+ copyFileSync(join(pending.package_dir, 'package.json'), join(runtimeNext, 'package.json'));
558
+ const sourceNodeModules = join(pending.package_dir, 'node_modules');
559
+ if (existsSync(sourceNodeModules)) {
560
+ cpSync(sourceNodeModules, join(runtimeNext, 'node_modules'), { recursive: true, force: true });
561
+ }
562
+ validatePackage(runtimeNext, pending.target_version);
563
+ }
564
+
565
+ await renameWithRetry(runtimeDir, runtimeBackup, deps);
566
+ try {
567
+ await renameWithRetry(runtimeNext, runtimeDir, deps);
568
+ validatePackage(runtimeDir, pending.target_version);
569
+ } catch (error) {
570
+ if (existsSync(runtimeBackup)) {
571
+ const failed = `${runtimeDir}${PACKAGE_FAILED_SUFFIX}`;
572
+ rmSync(failed, { recursive: true, force: true });
573
+ if (existsSync(runtimeDir)) await renameWithRetry(runtimeDir, failed, deps);
574
+ await renameWithRetry(runtimeBackup, runtimeDir, deps);
575
+ rmSync(failed, { recursive: true, force: true });
576
+ }
577
+ throw error;
578
+ }
579
+ }
580
+
581
+ async function restoreBackupTree(activeDir, backupDir, deps = {}) {
582
+ const failedDir = `${activeDir}${PACKAGE_FAILED_SUFFIX}`;
583
+ rmSync(failedDir, { recursive: true, force: true });
584
+ let activeMoved = false;
585
+ if (existsSync(activeDir)) {
586
+ await renameWithRetry(activeDir, failedDir, deps);
587
+ activeMoved = true;
588
+ }
589
+ try {
590
+ await renameWithRetry(backupDir, activeDir, deps);
591
+ } catch (error) {
592
+ // A failed backup restore must not leave the stable path empty. Put the
593
+ // complete target back and retain the source backup for a later retry.
594
+ if (activeMoved && !existsSync(activeDir) && existsSync(failedDir)) {
595
+ try { await renameWithRetry(failedDir, activeDir, deps); } catch { /* surface original restore failure */ }
596
+ }
597
+ throw error;
598
+ }
599
+ rmSync(failedDir, { recursive: true, force: true });
600
+ }
601
+
602
+ function powershellPath(env = process.env) {
603
+ return `${env.SystemRoot || 'C:\\Windows'}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
604
+ }
605
+
606
+ function quoteWindowsArg(value) {
607
+ if (String(value).includes('"')) throw new Error('Windows helper path contains an unsupported quote');
608
+ return `"${value}"`;
609
+ }
610
+
611
+ function launchIndependentMonitor(rootDir) {
612
+ const scriptPath = fileURLToPath(import.meta.url);
613
+ if (process.platform !== 'win32') {
614
+ const child = spawn(process.execPath, [scriptPath, 'monitor', '--root', rootDir], {
615
+ detached: true,
616
+ stdio: 'ignore',
617
+ });
618
+ child.unref();
619
+ return child.pid;
620
+ }
621
+ const commandLine = [process.execPath, scriptPath, 'monitor', '--root', rootDir]
622
+ .map(quoteWindowsArg)
623
+ .join(' ');
624
+ const escaped = commandLine.replace(/'/g, "''");
625
+ const ps = `$r=Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{CommandLine='${escaped}'}; if($r.ReturnValue -ne 0){exit $r.ReturnValue}; [Console]::Out.Write($r.ProcessId)`;
626
+ const encoded = Buffer.from(ps, 'utf16le').toString('base64');
627
+ const output = execFileSync(
628
+ powershellPath(),
629
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
630
+ { encoding: 'utf8', timeout: 10_000, windowsHide: true },
631
+ ).trim();
632
+ const pid = Number(output);
633
+ if (!Number.isInteger(pid) || pid <= 0) throw new Error(`monitor launch returned invalid pid: ${output}`);
634
+ return pid;
635
+ }
636
+
637
+ export function healthMatches(payload, pending, daemonStatus) {
638
+ const activationStartedAt = typeof pending.activation_started_at === 'number'
639
+ ? pending.activation_started_at
640
+ : Date.parse(pending.activation_started_at ?? '');
641
+ return Number.isFinite(activationStartedAt)
642
+ && Number.isInteger(pending.old_pid)
643
+ && pending.old_pid > 0
644
+ && payload?.status === 'ok'
645
+ && payload?.version === pending.target_version
646
+ && Number.isInteger(payload?.pid)
647
+ && payload.pid > 0
648
+ && payload.pid !== pending.old_pid
649
+ && daemonStatus?.state === 'running'
650
+ && daemonStatus?.pid === payload.pid
651
+ && typeof daemonStatus?.updated_at === 'number'
652
+ && daemonStatus.updated_at >= activationStartedAt;
653
+ }
654
+
655
+ async function fetchHealth(rootDir) {
656
+ const paths = pathsFor(rootDir);
657
+ let port = '19579';
658
+ try { port = readFileSync(paths.healthPort, 'utf8').trim() || port; } catch { /* default */ }
659
+ const response = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(2_000) });
660
+ if (!response.ok) return null;
661
+ return response.json();
662
+ }
663
+
664
+ async function readDaemonStatus(rootDir) {
665
+ return readJson(pathsFor(rootDir).daemonStatus);
666
+ }
667
+
668
+ async function readDaemonLock(rootDir) {
669
+ return readJson(pathsFor(rootDir).daemonLock);
670
+ }
671
+
672
+ async function readWrapperChild(rootDir) {
673
+ return readJson(pathsFor(rootDir).wrapperChild);
674
+ }
675
+
676
+ function currentBootTimestamp(deps = {}) {
677
+ return deps.bootTime ?? (Date.now() - uptime() * 1000);
678
+ }
679
+
680
+ function activationTimestamp(pending) {
681
+ return typeof pending.activation_started_at === 'number'
682
+ ? pending.activation_started_at
683
+ : Date.parse(pending.activation_started_at ?? '');
684
+ }
685
+
686
+ function postActivationDaemonPid(daemonStatus, pending, deps = {}) {
687
+ const activationStartedAt = activationTimestamp(pending);
688
+ const earliestGenerationTime = Math.max(activationStartedAt, currentBootTimestamp(deps) - 60_000);
689
+ return Number.isFinite(activationStartedAt)
690
+ && ['starting', 'running', 'stopping', 'failed'].includes(daemonStatus?.state)
691
+ && Number.isInteger(daemonStatus?.pid)
692
+ && daemonStatus.pid > 0
693
+ && daemonStatus.pid !== pending.old_pid
694
+ && typeof daemonStatus?.instance_id === 'string'
695
+ && daemonStatus.instance_id.length > 0
696
+ && typeof daemonStatus?.updated_at === 'number'
697
+ && daemonStatus.updated_at >= earliestGenerationTime
698
+ ? daemonStatus.pid
699
+ : null;
700
+ }
701
+
702
+ function verifiedTargetDaemonPid(rootDir, runtimeDir, daemonStatus, daemonLock, pending, deps = {}) {
703
+ const candidatePid = postActivationDaemonPid(daemonStatus, pending, deps);
704
+ if (candidatePid === null) return null;
705
+ const activationStartedAt = activationTimestamp(pending);
706
+ const earliestGenerationTime = Math.max(activationStartedAt, currentBootTimestamp(deps) - 60_000);
707
+ const recordedCommandLine = typeof daemonLock?.command_line === 'string'
708
+ ? daemonLock.command_line.trim()
709
+ : '';
710
+ const expectedCli = join(runtimeDir, 'dist', 'grix.js').replace(/\\/g, '/').toLowerCase();
711
+ if (
712
+ daemonLock?.pid !== candidatePid
713
+ || typeof daemonLock?.root_dir !== 'string'
714
+ || resolve(daemonLock.root_dir) !== resolve(rootDir)
715
+ || typeof daemonLock?.instance_id !== 'string'
716
+ || daemonLock.instance_id.length === 0
717
+ || daemonLock.instance_id !== daemonStatus.instance_id
718
+ || typeof daemonLock?.acquired_at !== 'number'
719
+ || daemonLock.acquired_at < earliestGenerationTime
720
+ || recordedCommandLine.length === 0
721
+ || !recordedCommandLine.replace(/\\/g, '/').toLowerCase().includes(expectedCli)
722
+ ) return null;
723
+ const currentCommandLine = readProcessCommandLine(candidatePid, deps);
724
+ return currentCommandLine !== null && currentCommandLine === recordedCommandLine
725
+ ? candidatePid
726
+ : null;
727
+ }
728
+
729
+ function wrapperChildPid(record, runtimeDir, pending, deps = {}) {
730
+ const activationStartedAt = activationTimestamp(pending);
731
+ const currentBootTime = currentBootTimestamp(deps);
732
+ const expectedCli = join(runtimeDir, 'dist', 'grix.js');
733
+ return Number.isFinite(activationStartedAt)
734
+ && Number.isInteger(record?.pid)
735
+ && record.pid > 0
736
+ && record.pid !== pending.old_pid
737
+ && typeof record?.launched_at === 'number'
738
+ && record.launched_at >= Math.max(activationStartedAt, currentBootTime - 60_000)
739
+ && typeof record?.boot_time === 'number'
740
+ && Math.abs(record.boot_time - currentBootTime) <= 60_000
741
+ && typeof record?.cli_path === 'string'
742
+ && resolve(record.cli_path) === resolve(expectedCli)
743
+ ? record.pid
744
+ : null;
745
+ }
746
+
747
+ function verifiedWrapperChildPid(record, runtimeDir, pending, deps = {}) {
748
+ const candidatePid = wrapperChildPid(record, runtimeDir, pending, deps);
749
+ if (candidatePid === null) return null;
750
+ const expectedCli = join(runtimeDir, 'dist', 'grix.js').replace(/\\/g, '/').toLowerCase();
751
+ const currentCommandLine = readProcessCommandLine(candidatePid, deps);
752
+ return currentCommandLine !== null
753
+ && currentCommandLine.replace(/\\/g, '/').toLowerCase().includes(expectedCli)
754
+ ? candidatePid
755
+ : null;
756
+ }
757
+
758
+ /**
759
+ * The active package is already the verified target at this point. Refresh the
760
+ * package-out guardian through a same-directory temporary file so the next
761
+ * upgrade never depends on the package version that is about to be replaced.
762
+ */
763
+ export async function installActiveGuardian(rootDir, pending, deps = {}) {
764
+ const paths = pathsFor(rootDir);
765
+ const source = join(pending.package_dir, 'scripts', 'upgrade-guardian.mjs');
766
+ if (!isPathInside(pending.package_dir, source) || !existsSync(source)) {
767
+ throw new Error(`active package guardian is missing: ${source}`);
768
+ }
769
+ mkdirSync(dirname(paths.installedGuardian), { recursive: true });
770
+ const temporary = `${paths.installedGuardian}.tmp-${process.pid}`;
771
+ const copy = deps.copyFile ?? copyFileSync;
772
+ try {
773
+ // Keep the transaction creator's helper as a fallback. If antivirus
774
+ // quarantines the newly installed primary during the next handoff, the
775
+ // wrapper can still finish that transaction with its matching schema.
776
+ try {
777
+ if (existsSync(paths.installedGuardian)) copy(paths.installedGuardian, paths.fallbackGuardian);
778
+ } catch { /* primary remains authoritative */ }
779
+ copy(source, temporary);
780
+ await renameWithRetry(temporary, paths.installedGuardian, deps);
781
+ } finally {
782
+ try { rmSync(temporary, { force: true }); } catch { /* best effort */ }
783
+ }
784
+ }
785
+
786
+ async function restorePreviousVersion(rootDir, pending, deps = {}) {
787
+ const paths = pathsFor(rootDir);
788
+ writeJsonAtomic(paths.pause, { transaction_id: pending.transaction_id });
789
+ updatePending(paths.pending, { phase: 'rolling_back', error_code: 'STARTUP_CRASH' });
790
+
791
+ if (transactionStrategy(pending) === 'service-start') {
792
+ await restorePreviousVersionByServiceStart(rootDir, pending, paths, deps);
793
+ return;
794
+ }
795
+
796
+ const daemonStatus = await (deps.readDaemonStatus ?? readDaemonStatus)(rootDir).catch(() => null);
797
+ const daemonLock = await (deps.readDaemonLock ?? readDaemonLock)(rootDir).catch(() => null);
798
+ const wrapperChild = await (deps.readWrapperChild ?? readWrapperChild)(rootDir).catch(() => null);
799
+ const expectedRuntimeDir = resolveTransactionRuntimeDir(rootDir, pending, deps);
800
+ const statusPid = postActivationDaemonPid(daemonStatus, pending, deps);
801
+ const launchedPid = wrapperChildPid(wrapperChild, expectedRuntimeDir, pending, deps);
802
+ const alive = deps.isPidAlive ?? isPidAlive;
803
+ const liveCandidates = [...new Set([statusPid, launchedPid]
804
+ .filter((pid) => pid !== null && alive(pid)))];
805
+ if (liveCandidates.length > 1) {
806
+ throw new Error(`multiple target daemon candidates are alive (${liveCandidates.join(', ')}); refusing rollback`);
807
+ }
808
+ if (liveCandidates.length === 1) {
809
+ const candidatePid = liveCandidates[0];
810
+ const verifiedByDaemonState = candidatePid === statusPid && verifiedTargetDaemonPid(
811
+ rootDir,
812
+ expectedRuntimeDir,
813
+ daemonStatus,
814
+ daemonLock,
815
+ pending,
816
+ deps,
817
+ );
818
+ const verifiedByWrapper = candidatePid === launchedPid
819
+ && verifiedWrapperChildPid(wrapperChild, expectedRuntimeDir, pending, deps);
820
+ const targetPid = verifiedByDaemonState || verifiedByWrapper;
821
+ if (targetPid === null || targetPid === false) {
822
+ throw new Error(`target daemon ${candidatePid} identity could not be verified; refusing rollback`);
823
+ }
824
+ if (deps.terminatePid) {
825
+ await deps.terminatePid(targetPid);
826
+ } else if (process.platform === 'win32') {
827
+ try { execFileSync('taskkill', ['/PID', String(targetPid), '/T', '/F'], { timeout: 10_000, windowsHide: true }); } catch { /* already gone */ }
828
+ } else {
829
+ try { process.kill(targetPid, 'SIGTERM'); } catch { /* already gone */ }
830
+ }
831
+ if (!(await waitForPidExit(targetPid, 10_000, deps))) {
832
+ throw new Error(`target daemon ${targetPid} did not exit; refusing to move active package or runtime`);
833
+ }
834
+ }
835
+
836
+ const { runtimeDir, runtimeBackup } = validateRuntimeTransaction(pending, expectedRuntimeDir);
837
+ const packageSourceVersion = pending.package_from_version ?? pending.from_version;
838
+ const packageWasSwitched = pending.package_activation_mode === 'switched'
839
+ || (pending.package_activation_mode === undefined && packageSourceVersion !== pending.target_version);
840
+ const needsRuntimeRestore = packageVersion(runtimeDir) !== pending.from_version;
841
+ const needsPackageRestore = packageWasSwitched
842
+ && packageVersion(pending.package_dir) !== packageSourceVersion;
843
+ // Validate both offline recovery sources before moving either active tree.
844
+ // This preserves the last runnable pair when a transaction is corrupt.
845
+ if (needsRuntimeRestore && packageVersion(runtimeBackup) !== pending.from_version) {
846
+ throw new Error('previous managed runtime backup is missing or invalid');
847
+ }
848
+ if (needsPackageRestore && packageVersion(pending.backup_dir) !== packageSourceVersion) {
849
+ throw new Error('previous package backup is missing or invalid');
850
+ }
851
+
852
+ if (needsRuntimeRestore) {
853
+ await restoreBackupTree(runtimeDir, runtimeBackup, deps);
854
+ }
855
+
856
+ if (needsPackageRestore) {
857
+ await restoreBackupTree(pending.package_dir, pending.backup_dir, deps);
858
+ }
859
+ validatePackage(
860
+ pending.package_dir,
861
+ packageWasSwitched ? packageSourceVersion : pending.target_version,
862
+ );
863
+ validatePackage(runtimeDir, pending.from_version);
864
+
865
+ updatePending(paths.pending, { phase: 'rolled_back', error_code: 'STARTUP_CRASH' });
866
+ rmSync(paths.pause, { force: true });
867
+ clearRollbackIntent(paths, pending.transaction_id);
868
+ appendLog(paths.log, `rolled back managed runtime to ${pending.from_version}; global package is ${packageVersion(pending.package_dir)}`);
869
+ }
870
+
871
+ /**
872
+ * service-start 策略的回滚:daemon 跑的是受管 runtime 拷贝,不占用全局包目录,
873
+ * 包可以在服务运行中原子换回;随后由恢复出的旧版 CLI `start` 刷新 runtime 并
874
+ * 重启服务——它自己会替换正在运行的实例,无需在这里识别并杀进程。
875
+ */
876
+ async function restorePreviousVersionByServiceStart(rootDir, pending, paths, deps = {}) {
877
+ const packageSourceVersion = pending.package_from_version ?? pending.from_version;
878
+ const packageWasSwitched = pending.package_activation_mode === 'switched'
879
+ || (pending.package_activation_mode === undefined && packageSourceVersion !== pending.target_version);
880
+ const needsPackageRestore = packageWasSwitched
881
+ && packageVersion(pending.package_dir) !== packageSourceVersion;
882
+ if (needsPackageRestore && packageVersion(pending.backup_dir) !== packageSourceVersion) {
883
+ throw new Error('previous package backup is missing or invalid');
884
+ }
885
+ if (needsPackageRestore) {
886
+ await restoreBackupTree(pending.package_dir, pending.backup_dir, deps);
887
+ }
888
+ validatePackage(
889
+ pending.package_dir,
890
+ packageWasSwitched ? packageSourceVersion : pending.target_version,
891
+ );
892
+ const restoredCli = join(pending.package_dir, 'dist', 'grix.js');
893
+ if (!existsSync(restoredCli)) throw new Error(`restored package CLI is missing: ${restoredCli}`);
894
+ // 激活失败必须让事务落 failed:包虽已恢复,但受管 runtime 仍是目标版本,
895
+ // 不刷新就宣告 rolled_back 会让 launchd 继续跑没人守护的坏版本。
896
+ (deps.runServiceStart ?? defaultRunServiceStart)(restoredCli);
897
+
898
+ updatePending(paths.pending, { phase: 'rolled_back', error_code: 'STARTUP_CRASH' });
899
+ rmSync(paths.pause, { force: true });
900
+ clearRollbackIntent(paths, pending.transaction_id);
901
+ appendLog(paths.log, `service-start rollback restored ${packageVersion(pending.package_dir)} and reactivated the managed service`);
902
+ }
903
+
904
+ async function launchVerificationMonitor(rootDir, pending, paths, injected) {
905
+ const current = validateTransaction(readJson(paths.pending));
906
+ if (monitorIdentityAlive(current, paths, injected)) {
907
+ rmSync(paths.pause, { force: true });
908
+ return 'verifying';
909
+ }
910
+ const verificationStartedAt = current.verification_started_at
911
+ ?? new Date((injected.now ?? Date.now)()).toISOString();
912
+ updatePending(paths.pending, {
913
+ phase: 'verifying',
914
+ guardian_pid: undefined,
915
+ guardian_started_at: undefined,
916
+ verification_started_at: verificationStartedAt,
917
+ });
918
+ const monitorPid = await (injected.launchMonitor ?? launchIndependentMonitor)(rootDir);
919
+ updatePending(paths.pending, {
920
+ guardian_pid: monitorPid,
921
+ guardian_started_at: new Date((injected.now ?? Date.now)()).toISOString(),
922
+ });
923
+ rmSync(paths.pause, { force: true });
924
+ appendLog(paths.log, `activated ${pending.target_version}; monitor pid=${monitorPid}`);
925
+ return 'verifying';
926
+ }
927
+
928
+ export async function activateWindowsUpgrade(rootDir, injected = {}) {
929
+ const paths = pathsFor(rootDir);
930
+ let rawPending;
931
+ try {
932
+ rawPending = readJson(paths.pending);
933
+ } catch {
934
+ rmSync(paths.pause, { force: true });
935
+ appendLog(paths.log, 'cleared orphaned wrapper pause marker because the pending transaction is missing or unreadable');
936
+ return 'stale_marker_cleared';
937
+ }
938
+
939
+ let pending;
940
+ try {
941
+ pending = validateTransaction(rawPending);
942
+ } catch (error) {
943
+ rmSync(paths.pause, { force: true });
944
+ appendLog(paths.log, `cleared wrapper pause marker for an invalid transaction: ${error instanceof Error ? error.message : String(error)}`);
945
+ return 'stale_marker_cleared';
946
+ }
947
+ const rollbackIntent = readRollbackIntent(paths, pending.transaction_id);
948
+ if (
949
+ rollbackIntent
950
+ && (pending.phase === 'verifying' || pending.phase === 'healthy' || pending.phase === 'rolling_back')
951
+ ) {
952
+ pending = updatePending(paths.pending, {
953
+ phase: 'rolling_back',
954
+ guardian_pid: undefined,
955
+ error_code: 'STARTUP_CRASH',
956
+ error_msg: rollbackIntent.message,
957
+ }, { transactionId: pending.transaction_id });
958
+ }
959
+ let hasMatchingPause = false;
960
+ if (existsSync(paths.pause)) {
961
+ let pauseTransactionId;
962
+ try { pauseTransactionId = readJson(paths.pause)?.transaction_id; } catch { /* stale/corrupt marker */ }
963
+ if (pauseTransactionId !== pending.transaction_id) {
964
+ rmSync(paths.pause, { force: true });
965
+ appendLog(paths.log, `cleared stale wrapper pause marker (marker=${String(pauseTransactionId)}, pending=${pending.transaction_id})`);
966
+ return 'stale_marker_cleared';
967
+ }
968
+ hasMatchingPause = true;
969
+ }
970
+ const runtimeDir = resolveTransactionRuntimeDir(rootDir, pending, injected);
971
+ validateRuntimeTransaction(pending, runtimeDir);
972
+ if (pending.phase === 'healthy' || pending.phase === 'rolled_back' || pending.phase === 'finalizing') {
973
+ rmSync(paths.pause, { force: true });
974
+ return pending.phase;
975
+ }
976
+ if (pending.phase === 'failed') {
977
+ // 回滚源损坏时保留 pending 供人工诊断,但不能让 wrapper 因 marker 永久停服。
978
+ rmSync(paths.pause, { force: true });
979
+ appendLog(paths.log, 'manual recovery is required; released wrapper to run the last intact runtime');
980
+ return 'failed';
981
+ }
982
+ if (pending.phase === 'preparing' || (
983
+ (pending.phase === 'staged' || pending.phase === 'handoff_ready')
984
+ && !hasMatchingPause
985
+ )) {
986
+ // A plain pending file is not authorization to cross the idle/shutdown
987
+ // boundary. This is important because the wrapper also invokes us after an
988
+ // unrelated daemon crash while staging may still be in progress.
989
+ appendLog(paths.log, `transaction ${pending.transaction_id} is waiting for an explicit wrapper handoff`);
990
+ return 'waiting_for_handoff';
991
+ }
992
+ validateNpmPrefixLock(pending, paths.pending);
993
+ if (
994
+ !injected.wrapperHandoffComplete
995
+ && pending.old_pid
996
+ && !(await waitForOriginalProcessExit(pending, 15_000, injected))
997
+ ) {
998
+ throw new Error(`old daemon ${pending.old_pid} is still running`);
999
+ }
1000
+
1001
+ if (pending.phase === 'rolling_back') {
1002
+ try {
1003
+ await restorePreviousVersion(rootDir, pending, injected);
1004
+ return 'rolled_back';
1005
+ } catch (error) {
1006
+ updatePending(paths.pending, {
1007
+ phase: 'failed',
1008
+ error_code: 'ROLLBACK_FAILED',
1009
+ error_msg: error instanceof Error ? error.message : String(error),
1010
+ });
1011
+ throw error;
1012
+ }
1013
+ }
1014
+
1015
+ if (pending.phase === 'verifying') {
1016
+ try {
1017
+ validatePackage(pending.package_dir, pending.target_version);
1018
+ // service-start 策略没有 guardian 管理的 runtime 目录,健康与否由 monitor
1019
+ // 按 healthz/daemon-status 裁决即可。
1020
+ if (transactionStrategy(pending) === 'wrapper-runtime') {
1021
+ validatePackage(pending.runtime_dir ?? runtimeDir, pending.target_version);
1022
+ }
1023
+ return await launchVerificationMonitor(rootDir, pending, paths, injected);
1024
+ } catch (error) {
1025
+ appendLog(paths.log, `verification resume failed: ${error instanceof Error ? error.message : String(error)}`);
1026
+ try {
1027
+ await restorePreviousVersion(rootDir, pending, injected);
1028
+ return 'rolled_back';
1029
+ } catch (rollbackError) {
1030
+ updatePending(paths.pending, {
1031
+ phase: 'failed',
1032
+ error_code: 'ROLLBACK_FAILED',
1033
+ error_msg: rollbackError instanceof Error ? rollbackError.message : String(rollbackError),
1034
+ });
1035
+ throw rollbackError;
1036
+ }
1037
+ }
1038
+ }
1039
+
1040
+ try {
1041
+ updatePending(paths.pending, { phase: 'activating', guardian_pid: process.pid });
1042
+ await switchPackage(pending, { ...injected, pendingFile: paths.pending });
1043
+ if (transactionStrategy(pending) === 'wrapper-runtime') {
1044
+ const current = validateTransaction(readJson(paths.pending));
1045
+ await deployManagedRuntime(current, runtimeDir, { ...injected, pendingFile: paths.pending });
1046
+ } else {
1047
+ // service-start:已切换的目标 CLI `start` 会刷新受管 runtime 副本、重写
1048
+ // 服务定义并重启服务(替换 launchd 拉起的旧 runtime 实例)。失败视同激活
1049
+ // 失败,走下方回滚。
1050
+ (injected.runServiceStart ?? defaultRunServiceStart)(join(pending.package_dir, 'dist', 'grix.js'));
1051
+ }
1052
+ try { rmSync(pending.staging_prefix, { recursive: true, force: true }); } catch { /* best effort */ }
1053
+ return await launchVerificationMonitor(rootDir, pending, paths, injected);
1054
+ } catch (error) {
1055
+ appendLog(paths.log, `activation failed: ${error instanceof Error ? error.message : String(error)}`);
1056
+ try {
1057
+ const current = validateTransaction(readJson(paths.pending));
1058
+ await restorePreviousVersion(rootDir, current, injected);
1059
+ return 'rolled_back';
1060
+ } catch (rollbackError) {
1061
+ updatePending(paths.pending, {
1062
+ phase: 'failed',
1063
+ error_code: 'ROLLBACK_FAILED',
1064
+ error_msg: rollbackError instanceof Error ? rollbackError.message : String(rollbackError),
1065
+ });
1066
+ throw rollbackError;
1067
+ }
1068
+ }
1069
+ }
1070
+
1071
+ export async function monitorWindowsUpgrade(rootDir, injected = {}) {
1072
+ const paths = pathsFor(rootDir);
1073
+ let pending = validateTransaction(readJson(paths.pending));
1074
+ const transactionId = pending.transaction_id;
1075
+ const monitorPid = injected.monitorPid ?? process.pid;
1076
+ const fence = {
1077
+ transactionId,
1078
+ phase: 'verifying',
1079
+ ...(Number.isInteger(pending.guardian_pid) ? { guardianPid: monitorPid } : {}),
1080
+ };
1081
+ const ownsVerification = (candidate) => candidate.transaction_id === transactionId
1082
+ && candidate.phase === 'verifying'
1083
+ && (fence.guardianPid === undefined || candidate.guardian_pid === monitorPid);
1084
+ if (!ownsVerification(pending)) return 'superseded';
1085
+ const runtimeDir = resolveTransactionRuntimeDir(rootDir, pending, injected);
1086
+ validateRuntimeTransaction(pending, runtimeDir);
1087
+ validateNpmPrefixLock(pending, paths.pending);
1088
+ const probe = injected.fetchHealth ?? fetchHealth;
1089
+ const readStatus = injected.readDaemonStatus ?? readDaemonStatus;
1090
+ const wait = injected.sleep ?? sleep;
1091
+ const now = injected.now ?? Date.now;
1092
+ const timeoutMs = injected.healthTimeoutMs ?? HEALTH_TIMEOUT_MS;
1093
+ const rollbackIfRequested = async () => {
1094
+ const intent = readRollbackIntent(paths, transactionId);
1095
+ if (!intent) return null;
1096
+ let current = validateTransaction(readJson(paths.pending));
1097
+ if (current.transaction_id !== transactionId) return 'superseded';
1098
+ if (current.phase === 'rolled_back' || current.phase === 'finalizing') {
1099
+ clearRollbackIntent(paths, transactionId);
1100
+ return current.phase;
1101
+ }
1102
+ if (current.phase === 'failed') return 'failed';
1103
+ current = updatePending(paths.pending, {
1104
+ phase: 'rolling_back',
1105
+ guardian_pid: undefined,
1106
+ error_code: 'STARTUP_CRASH',
1107
+ error_msg: intent.message,
1108
+ }, { transactionId });
1109
+ appendLog(paths.log, `durable rollback intent won verification for ${transactionId}`);
1110
+ try {
1111
+ await restorePreviousVersion(rootDir, current, injected);
1112
+ rmSync(paths.monitorHeartbeat, { force: true });
1113
+ return 'rolled_back';
1114
+ } catch (error) {
1115
+ updatePending(paths.pending, {
1116
+ phase: 'failed',
1117
+ error_code: 'ROLLBACK_FAILED',
1118
+ error_msg: error instanceof Error ? error.message : String(error),
1119
+ }, { transactionId });
1120
+ throw error;
1121
+ }
1122
+ };
1123
+ const verificationStartedAt = Date.parse(
1124
+ pending.verification_started_at ?? pending.activation_started_at ?? '',
1125
+ );
1126
+ // Production uses an absolute transaction deadline, so restarting a killed
1127
+ // monitor cannot extend an unhealthy target indefinitely. Tests may inject a
1128
+ // short relative timeout for deterministic clocks.
1129
+ const deadline = injected.healthTimeoutMs === undefined && Number.isFinite(verificationStartedAt)
1130
+ ? verificationStartedAt + timeoutMs
1131
+ : now() + timeoutMs;
1132
+ let consecutive = 0;
1133
+ const writeHeartbeat = () => writeJsonAtomic(paths.monitorHeartbeat, {
1134
+ transaction_id: transactionId,
1135
+ pid: monitorPid,
1136
+ updated_at: now(),
1137
+ });
1138
+ writeHeartbeat();
1139
+ while (now() < deadline) {
1140
+ const rollbackResult = await rollbackIfRequested();
1141
+ if (rollbackResult) return rollbackResult;
1142
+ pending = validateTransaction(readJson(paths.pending));
1143
+ if (!ownsVerification(pending)) {
1144
+ rmSync(paths.monitorHeartbeat, { force: true });
1145
+ return 'superseded';
1146
+ }
1147
+ writeHeartbeat();
1148
+ const health = await probe(rootDir).catch(() => null);
1149
+ const daemonStatus = await readStatus(rootDir).catch(() => null);
1150
+ if (healthMatches(health, pending, daemonStatus)) {
1151
+ consecutive += 1;
1152
+ if (consecutive >= 2) {
1153
+ try {
1154
+ const rollbackBeforeCommit = await rollbackIfRequested();
1155
+ if (rollbackBeforeCommit) return rollbackBeforeCommit;
1156
+ pending = validateTransaction(readJson(paths.pending));
1157
+ if (!ownsVerification(pending)) {
1158
+ rmSync(paths.monitorHeartbeat, { force: true });
1159
+ return 'superseded';
1160
+ }
1161
+ await installActiveGuardian(rootDir, pending, injected);
1162
+ const rollbackAfterGuardianInstall = await rollbackIfRequested();
1163
+ if (rollbackAfterGuardianInstall) return rollbackAfterGuardianInstall;
1164
+ updatePending(paths.pending, {
1165
+ phase: 'healthy',
1166
+ guardian_pid: undefined,
1167
+ guardian_started_at: undefined,
1168
+ }, fence);
1169
+ const rollbackAfterCommit = await rollbackIfRequested();
1170
+ if (rollbackAfterCommit) return rollbackAfterCommit;
1171
+ rmSync(paths.monitorHeartbeat, { force: true });
1172
+ appendLog(paths.log, `target ${pending.target_version} passed exact health checks`);
1173
+ return 'healthy';
1174
+ } catch (error) {
1175
+ consecutive = 0;
1176
+ appendLog(paths.log, `failed to refresh package-out guardian: ${error instanceof Error ? error.message : String(error)}`);
1177
+ }
1178
+ }
1179
+ } else {
1180
+ consecutive = 0;
1181
+ }
1182
+ await wait(HEALTH_POLL_MS);
1183
+ }
1184
+ pending = validateTransaction(readJson(paths.pending));
1185
+ if (!ownsVerification(pending)) {
1186
+ rmSync(paths.monitorHeartbeat, { force: true });
1187
+ return 'superseded';
1188
+ }
1189
+ updatePending(paths.pending, {
1190
+ phase: 'rolling_back',
1191
+ error_code: 'STARTUP_CRASH',
1192
+ }, fence);
1193
+ pending = validateTransaction(readJson(paths.pending));
1194
+ appendLog(paths.log, `target ${pending.target_version} failed health verification; rolling back`);
1195
+ try {
1196
+ await restorePreviousVersion(rootDir, pending, injected);
1197
+ rmSync(paths.monitorHeartbeat, { force: true });
1198
+ return 'rolled_back';
1199
+ } catch (error) {
1200
+ updatePending(paths.pending, {
1201
+ phase: 'failed',
1202
+ error_code: 'ROLLBACK_FAILED',
1203
+ error_msg: error instanceof Error ? error.message : String(error),
1204
+ });
1205
+ appendLog(paths.log, `offline rollback failed: ${error instanceof Error ? error.message : String(error)}`);
1206
+ throw error;
1207
+ }
1208
+ }
1209
+
1210
+ /**
1211
+ * 找出跑指定 wrapper 脚本的 wscript 进程。wmic 在 Win11 上已被移除,回落 CIM。
1212
+ */
1213
+ function findWrapperPids(wrapperFileName) {
1214
+ const parse = (text) => String(text || '')
1215
+ .split(/\r?\n/)
1216
+ .map((line) => Number.parseInt(line.trim(), 10))
1217
+ .filter((pid) => Number.isFinite(pid) && pid > 0);
1218
+ try {
1219
+ return parse(execFileSync(
1220
+ 'wmic',
1221
+ ['process', 'where', `CommandLine like '%${wrapperFileName}%'`, 'get', 'ProcessId'],
1222
+ { encoding: 'utf8', timeout: 15_000, windowsHide: true },
1223
+ ));
1224
+ } catch { /* fall through to CIM */ }
1225
+ try {
1226
+ const script = `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*${wrapperFileName}*' } | ForEach-Object { $_.ProcessId }`;
1227
+ return parse(execFileSync(
1228
+ `${process.env.SystemRoot || 'C:\\Windows'}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`,
1229
+ ['-NoProfile', '-NonInteractive', '-Command', script],
1230
+ { encoding: 'utf8', timeout: 20_000, windowsHide: true },
1231
+ ));
1232
+ } catch {
1233
+ return [];
1234
+ }
1235
+ }
1236
+
1237
+ /**
1238
+ * 存量安装的 wrapper 接管。
1239
+ *
1240
+ * 旧 wrapper 是纯 `Do shell.Run daemon Loop`,既不调 guardian 也不注入
1241
+ * GRIX_WRAPPER_SUPERVISED,事务化自升级因此永远被拒。daemon 刷新完磁盘上的
1242
+ * wrapper 后派本流程完成交接:
1243
+ *
1244
+ * 1. 先只杀 wscript 进程本身(不带 /T)。Windows 不级联杀子进程,daemon 因此
1245
+ * 毫发无伤,但旧 wrapper 的 5 秒重启循环就此停下,不会在交接窗口里再拉起
1246
+ * 一个 daemon 和我们抢。
1247
+ * 2. 等 daemon 自己优雅退出。
1248
+ * 3. 拉起刷新后的 wrapper。
1249
+ *
1250
+ * 第 3 步放在 finally:中间任何一步失败都必须把 wrapper 拉回来,否则我们刚亲手
1251
+ * 拆掉了这台机器的自启动监管。
1252
+ */
1253
+ async function adoptWindowsWrapper(rootDir, options) {
1254
+ const paths = pathsFor(rootDir);
1255
+ const { wrapperPath, serviceId, daemonPid } = options;
1256
+ if (!wrapperPath || !serviceId) {
1257
+ appendLog(paths.log, 'adopt-wrapper: missing --wrapper or --service-id; nothing to do');
1258
+ return;
1259
+ }
1260
+ // 升级事务在途时 wrapper 归 activate 流程管,这里退开。
1261
+ if (existsSync(paths.pending)) {
1262
+ appendLog(paths.log, 'adopt-wrapper: upgrade transaction in flight; skipping wrapper adoption');
1263
+ return;
1264
+ }
1265
+ let wrapperRestarted = false;
1266
+ try {
1267
+ const wrapperFileName = `${serviceId}-wrapper.vbs`;
1268
+ for (const pid of findWrapperPids(wrapperFileName)) {
1269
+ if (pid === process.pid) continue;
1270
+ // 只杀 wscript 自己:带 /T 会连同它启动的 daemon 一起杀,交接就不再优雅。
1271
+ try { execFileSync('taskkill', ['/PID', String(pid), '/F'], { timeout: 10_000, windowsHide: true }); } catch { /* already gone */ }
1272
+ appendLog(paths.log, `adopt-wrapper: stopped stale wrapper pid=${pid}`);
1273
+ }
1274
+
1275
+ if (daemonPid > 0) {
1276
+ const deadline = Date.now() + ADOPT_WRAPPER_DAEMON_EXIT_TIMEOUT_MS;
1277
+ while (Date.now() < deadline && isPidAlive(daemonPid)) await sleep(500);
1278
+ if (isPidAlive(daemonPid)) {
1279
+ // 不再等下去:没有 wrapper 的机器等于失去自启动监管,宁可让新 wrapper
1280
+ // 拉起的 daemon 通过单例锁把这个赖着不走的旧进程换掉。
1281
+ appendLog(paths.log, `adopt-wrapper: daemon pid=${daemonPid} still alive after timeout; restarting wrapper anyway`);
1282
+ } else {
1283
+ appendLog(paths.log, `adopt-wrapper: daemon pid=${daemonPid} exited`);
1284
+ }
1285
+ }
1286
+ } finally {
1287
+ try {
1288
+ spawn('wscript.exe', ['//B', '//NoLogo', wrapperPath], {
1289
+ detached: true,
1290
+ stdio: 'ignore',
1291
+ windowsHide: true,
1292
+ }).unref();
1293
+ wrapperRestarted = true;
1294
+ } catch (error) {
1295
+ appendLog(paths.log, `adopt-wrapper: failed to start refreshed wrapper: ${error instanceof Error ? error.message : String(error)}`);
1296
+ }
1297
+ }
1298
+ if (wrapperRestarted) appendLog(paths.log, `adopt-wrapper: started refreshed wrapper ${wrapperPath}`);
1299
+ }
1300
+
1301
+ async function runCli() {
1302
+ const args = process.argv.slice(2);
1303
+ const mode = args[0] || 'activate';
1304
+ const rootIndex = args.indexOf('--root');
1305
+ const rootDir = rootIndex >= 0 && args[rootIndex + 1]
1306
+ ? resolve(args[rootIndex + 1])
1307
+ : resolve(process.env.GRIX_CONNECTOR_HOME || join(homedir(), '.grix'));
1308
+ const paths = pathsFor(rootDir);
1309
+ const readArg = (name) => {
1310
+ const index = args.indexOf(name);
1311
+ return index >= 0 && args[index + 1] ? args[index + 1] : '';
1312
+ };
1313
+ let lockHandle = await acquireGuardianProcessLock(rootDir);
1314
+ if (!lockHandle && mode === 'monitor') {
1315
+ for (let attempt = 0; attempt < 40 && !lockHandle; attempt++) {
1316
+ await sleep(250);
1317
+ lockHandle = await acquireGuardianProcessLock(rootDir);
1318
+ }
1319
+ }
1320
+ if (!lockHandle) {
1321
+ // POSIX 上互斥用 hash 派生的本地端口,撞上无关服务就永远拿不到锁。
1322
+ // 静默 return 75 会让排查时看不出任何线索,至少留一行。
1323
+ appendLog(paths.log, `${mode}: another guardian holds the process lock (or its endpoint is occupied); giving up this round`);
1324
+ return 75;
1325
+ }
1326
+ try {
1327
+ if (mode === 'monitor') await monitorWindowsUpgrade(rootDir);
1328
+ else if (mode === 'adopt-wrapper') {
1329
+ await adoptWindowsWrapper(rootDir, {
1330
+ wrapperPath: readArg('--wrapper'),
1331
+ serviceId: readArg('--service-id'),
1332
+ daemonPid: Number.parseInt(readArg('--daemon-pid'), 10) || 0,
1333
+ });
1334
+ } else await activateWindowsUpgrade(rootDir, { wrapperHandoffComplete: args.includes('--wrapper-handoff') });
1335
+ return 0;
1336
+ } catch (error) {
1337
+ appendLog(paths.log, `${mode} failed: ${error instanceof Error ? error.stack || error.message : String(error)}`);
1338
+ return 1;
1339
+ } finally {
1340
+ await releaseGuardianProcessLock(lockHandle);
1341
+ }
1342
+ }
1343
+
1344
+ const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
1345
+ if (isMain) process.exitCode = await runCli();