@remodex/rmx 1.0.2 → 1.0.4

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.
@@ -0,0 +1,1671 @@
1
+ /**
2
+ * Cross-platform unattended Remodex updater.
3
+ *
4
+ * The scheduler is intentionally separate from the long-running proxy service:
5
+ * service mode must remain quiet, while this short-lived job owns the package
6
+ * replacement, restart confirmation, and rollback record.
7
+ */
8
+ import { spawnSync } from "node:child_process";
9
+ import {
10
+ appendFileSync,
11
+ closeSync,
12
+ existsSync,
13
+ mkdirSync,
14
+ openSync,
15
+ readFileSync,
16
+ renameSync,
17
+ statSync,
18
+ unlinkSync,
19
+ writeFileSync,
20
+ writeSync,
21
+ } from "node:fs";
22
+ import { randomUUID } from "node:crypto";
23
+ import { homedir } from "node:os";
24
+ import { basename, dirname, join, resolve } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import {
27
+ atomicWriteFile,
28
+ expandUserPath,
29
+ getConfigDir,
30
+ } from "../config";
31
+ import { recordOwnedConfigPath } from "../lib/config-ownership";
32
+ import { isProcessAlive } from "../lib/process-control";
33
+ import {
34
+ windowsEnvIndirectBatchPathList,
35
+ windowsEnvIndirectBatchValue,
36
+ } from "../lib/win-paths";
37
+ import { resolveTrustedWindowsSchtasksExe } from "../lib/windows-elevation";
38
+ import { findLiveProxy } from "../server/proxy-liveness";
39
+ import { probeWindowsSchedulerTask } from "../service";
40
+ import {
41
+ checkUpdatePackageIntegrity,
42
+ currentVersion,
43
+ defaultUpdateTag,
44
+ detectInstall,
45
+ PKG,
46
+ type Channel,
47
+ type Installer,
48
+ } from "./index";
49
+ import {
50
+ checkForUpdate,
51
+ packageLauncherPath,
52
+ readUpdateJob,
53
+ runGuiUpdateWorker,
54
+ staleActiveUpdateJobReason,
55
+ type UpdateJobState,
56
+ type UpdateCheckResult,
57
+ } from "./job";
58
+
59
+ export const AUTO_UPDATE_STATE_FILENAME = "auto-update.json";
60
+ export const AUTO_UPDATE_LOG_FILENAME = "auto-update.log";
61
+ export const AUTO_UPDATE_LOCK_FILENAME = "auto-update.lock";
62
+ export const AUTO_UPDATE_WINDOWS_SCRIPT_FILENAME = "auto-update.cmd";
63
+ export const AUTO_UPDATE_WINDOWS_XML_FILENAME = "auto-update-task.xml";
64
+ export const AUTO_UPDATE_WINDOWS_TASK_NAME = "Remodex-AutoUpdate";
65
+ export const AUTO_UPDATE_LAUNCHD_LABEL = "com.remodex.auto-update";
66
+ export const AUTO_UPDATE_SYSTEMD_SERVICE = "remodex-auto-update.service";
67
+ export const AUTO_UPDATE_SYSTEMD_TIMER = "remodex-auto-update.timer";
68
+
69
+ export const AUTO_UPDATE_DEFAULT_HOUR = 3;
70
+ export const AUTO_UPDATE_DEFAULT_MINUTE = 0;
71
+ export const AUTO_UPDATE_LOCK_STALE_MS = 6 * 60 * 60 * 1000;
72
+ export const AUTO_UPDATE_LOCK_MALFORMED_STALE_MS = 24 * 60 * 60 * 1000;
73
+ export const AUTO_UPDATE_HEALTH_TIMEOUT_MS = 45_000;
74
+ export const AUTO_UPDATE_COMMAND_TIMEOUT_MS = 8 * 60_000;
75
+
76
+ export type AutoUpdateSchedulerKind =
77
+ | "windows-task"
78
+ | "launchd"
79
+ | "systemd-user-timer";
80
+
81
+ export type AutoUpdateResult =
82
+ | "running"
83
+ | "updated"
84
+ | "already_current"
85
+ | "skipped"
86
+ | "failed"
87
+ | "busy";
88
+
89
+ export type AutoUpdateErrorCode =
90
+ | "source_checkout"
91
+ | "unsupported_platform"
92
+ | "scheduler_unavailable"
93
+ | "scheduler_registration_failed"
94
+ | "scheduler_query_failed"
95
+ | "invalid_channel"
96
+ | "invalid_state"
97
+ | "integrity_unavailable"
98
+ | "integrity_invalid"
99
+ | "update_unavailable"
100
+ | "update_failed"
101
+ | "health_failed"
102
+ | "rollback_integrity_unavailable"
103
+ | "rollback_failed"
104
+ | "worker_failed";
105
+
106
+ export interface AutoUpdateSchedule {
107
+ kind: "daily";
108
+ hour: number;
109
+ minute: number;
110
+ }
111
+
112
+ export interface AutoUpdateRollback {
113
+ attemptedAt: string;
114
+ version: string;
115
+ result: "running" | "succeeded" | "failed";
116
+ }
117
+
118
+ export interface AutoUpdateState {
119
+ version: 1;
120
+ enabled: boolean;
121
+ channel: Channel;
122
+ schedule: AutoUpdateSchedule;
123
+ createdAt: string;
124
+ updatedAt: string;
125
+ lastAttemptAt?: string;
126
+ lastFinishedAt?: string;
127
+ lastResult?: AutoUpdateResult;
128
+ currentVersion?: string;
129
+ targetVersion?: string;
130
+ previousVersion?: string;
131
+ lastErrorCode?: AutoUpdateErrorCode;
132
+ rollback?: AutoUpdateRollback;
133
+ lastJobId?: string;
134
+ }
135
+
136
+ export type AutoSchedulerPresence = "present" | "absent" | "unknown";
137
+
138
+ export interface AutoUpdatePaths {
139
+ configDir: string;
140
+ homeDir: string;
141
+ statePath: string;
142
+ logPath: string;
143
+ lockPath: string;
144
+ windowsScriptPath: string;
145
+ windowsXmlPath: string;
146
+ launchdPlistPath: string;
147
+ systemdServicePath: string;
148
+ systemdTimerPath: string;
149
+ }
150
+
151
+ export interface AutoUpdateRuntimePaths {
152
+ nodePath: string;
153
+ launcherPath: string;
154
+ configDir: string;
155
+ codexHome?: string;
156
+ path?: string;
157
+ }
158
+
159
+ export interface AutoUpdateCommandResult {
160
+ status: number | null;
161
+ stdout?: string;
162
+ stderr?: string;
163
+ }
164
+
165
+ export type AutoUpdateCommandRunner = (
166
+ file: string,
167
+ args: readonly string[],
168
+ ) => AutoUpdateCommandResult;
169
+
170
+ export interface AutoUpdateSchedulerDeps {
171
+ platform?: NodeJS.Platform;
172
+ configDir?: string;
173
+ homeDir?: string;
174
+ env?: NodeJS.ProcessEnv;
175
+ nodePath?: string;
176
+ launcherPath?: string;
177
+ uid?: number;
178
+ schtasksPath?: string;
179
+ launchctlPath?: string;
180
+ systemctlPath?: string;
181
+ runCommand?: AutoUpdateCommandRunner;
182
+ now?: () => number;
183
+ probeWindows?: () => AutoSchedulerPresence;
184
+ probeLaunchd?: () => AutoSchedulerPresence;
185
+ probeSystemd?: () => AutoSchedulerPresence;
186
+ installer?: Installer;
187
+ currentVersion?: string;
188
+ }
189
+
190
+ export interface AutoUpdateStatus {
191
+ enabled: boolean;
192
+ defaultEnabled: boolean;
193
+ supported: boolean;
194
+ installer: Installer;
195
+ channel: Channel;
196
+ schedule: AutoUpdateSchedule;
197
+ schedulerKind: AutoUpdateSchedulerKind | null;
198
+ scheduler: AutoSchedulerPresence;
199
+ state: AutoUpdateState | null;
200
+ paths: AutoUpdatePaths;
201
+ }
202
+
203
+ export interface AutoUpdateRunResult {
204
+ ok: boolean;
205
+ result: AutoUpdateResult;
206
+ currentVersion: string;
207
+ targetVersion: string | null;
208
+ rolledBack: boolean;
209
+ }
210
+
211
+ export interface AutoUpdateWorkerDeps {
212
+ now?: () => number;
213
+ checkForUpdateFn?: (channel: Channel) => UpdateCheckResult;
214
+ integrityFn?: (version: string) => ReturnType<typeof checkUpdatePackageIntegrity>;
215
+ runGuiWorkerFn?: (
216
+ jobId: string,
217
+ channel: Channel,
218
+ restart: boolean,
219
+ io: {
220
+ checkForUpdateFn?: (channel: Channel) => UpdateCheckResult;
221
+ integrityFn?: (version: string | null) => ReturnType<typeof checkUpdatePackageIntegrity>;
222
+ exactVersion?: string;
223
+ },
224
+ ) => Promise<void>;
225
+ currentVersionFn?: () => string;
226
+ installedVersionFn?: () => string | null;
227
+ isProcessAliveFn?: (pid: number) => boolean;
228
+ healthFn?: () => Promise<boolean>;
229
+ configDir?: string;
230
+ installer?: Installer;
231
+ readUpdateJobFn?: (jobId?: string | null) => UpdateJobState | null;
232
+ }
233
+
234
+ export class AutoUpdateError extends Error {
235
+ constructor(
236
+ message: string,
237
+ readonly code: AutoUpdateErrorCode,
238
+ ) {
239
+ super(message);
240
+ this.name = "AutoUpdateError";
241
+ }
242
+ }
243
+
244
+ const SAFE_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
245
+ const HERE = dirname(fileURLToPath(import.meta.url));
246
+ const INSTALLED_PACKAGE_ROOT = resolve(HERE, "..", "..");
247
+ const AUTO_RESULT_VALUES = new Set<AutoUpdateResult>([
248
+ "running",
249
+ "updated",
250
+ "already_current",
251
+ "skipped",
252
+ "failed",
253
+ "busy",
254
+ ]);
255
+ const AUTO_ERROR_VALUES = new Set<AutoUpdateErrorCode>([
256
+ "source_checkout",
257
+ "unsupported_platform",
258
+ "scheduler_unavailable",
259
+ "scheduler_registration_failed",
260
+ "scheduler_query_failed",
261
+ "invalid_channel",
262
+ "invalid_state",
263
+ "integrity_unavailable",
264
+ "integrity_invalid",
265
+ "update_unavailable",
266
+ "update_failed",
267
+ "health_failed",
268
+ "rollback_integrity_unavailable",
269
+ "rollback_failed",
270
+ "worker_failed",
271
+ ]);
272
+
273
+ export function isAutoUpdateVersion(value: unknown): value is string {
274
+ return typeof value === "string" && value.length <= 64 && SAFE_VERSION.test(value);
275
+ }
276
+
277
+ function isAutoUpdateChannel(value: unknown): value is Channel {
278
+ return value === "latest" || value === "preview";
279
+ }
280
+
281
+ function isSupportedPlatform(platform: NodeJS.Platform): platform is "win32" | "darwin" | "linux" {
282
+ return platform === "win32" || platform === "darwin" || platform === "linux";
283
+ }
284
+
285
+ export function autoUpdateSchedulerKind(
286
+ platform: NodeJS.Platform = process.platform,
287
+ ): AutoUpdateSchedulerKind | null {
288
+ if (platform === "win32") return "windows-task";
289
+ if (platform === "darwin") return "launchd";
290
+ if (platform === "linux") return "systemd-user-timer";
291
+ return null;
292
+ }
293
+
294
+ export function autoUpdatePaths(options: {
295
+ configDir?: string;
296
+ homeDir?: string;
297
+ } = {}): AutoUpdatePaths {
298
+ const configDir = resolve(options.configDir ?? getConfigDir());
299
+ const homeDir = resolve(options.homeDir ?? homedir());
300
+ const launchAgents = join(homeDir, "Library", "LaunchAgents");
301
+ const systemdUser = join(homeDir, ".config", "systemd", "user");
302
+ return {
303
+ configDir,
304
+ homeDir,
305
+ statePath: join(configDir, AUTO_UPDATE_STATE_FILENAME),
306
+ logPath: join(configDir, AUTO_UPDATE_LOG_FILENAME),
307
+ lockPath: join(configDir, AUTO_UPDATE_LOCK_FILENAME),
308
+ windowsScriptPath: join(configDir, AUTO_UPDATE_WINDOWS_SCRIPT_FILENAME),
309
+ windowsXmlPath: join(configDir, AUTO_UPDATE_WINDOWS_XML_FILENAME),
310
+ launchdPlistPath: join(launchAgents, `${AUTO_UPDATE_LAUNCHD_LABEL}.plist`),
311
+ systemdServicePath: join(systemdUser, AUTO_UPDATE_SYSTEMD_SERVICE),
312
+ systemdTimerPath: join(systemdUser, AUTO_UPDATE_SYSTEMD_TIMER),
313
+ };
314
+ }
315
+
316
+ export function autoUpdateStatePath(configDir = getConfigDir()): string {
317
+ return autoUpdatePaths({ configDir }).statePath;
318
+ }
319
+
320
+ export function autoUpdateLogPath(configDir = getConfigDir()): string {
321
+ return autoUpdatePaths({ configDir }).logPath;
322
+ }
323
+
324
+ export function autoUpdateLockPath(configDir = getConfigDir()): string {
325
+ return autoUpdatePaths({ configDir }).lockPath;
326
+ }
327
+
328
+ function isValidTimestamp(value: unknown): value is string {
329
+ return typeof value === "string" && value.length <= 64 && Number.isFinite(Date.parse(value));
330
+ }
331
+
332
+ function parseSchedule(value: unknown): AutoUpdateSchedule | null {
333
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
334
+ const schedule = value as Record<string, unknown>;
335
+ if (
336
+ schedule.kind !== "daily"
337
+ || !Number.isInteger(schedule.hour)
338
+ || !Number.isInteger(schedule.minute)
339
+ || Number(schedule.hour) < 0
340
+ || Number(schedule.hour) > 23
341
+ || Number(schedule.minute) < 0
342
+ || Number(schedule.minute) > 59
343
+ ) return null;
344
+ return {
345
+ kind: "daily",
346
+ hour: Number(schedule.hour),
347
+ minute: Number(schedule.minute),
348
+ };
349
+ }
350
+
351
+ function parseRollback(value: unknown): AutoUpdateRollback | undefined {
352
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
353
+ const rollback = value as Record<string, unknown>;
354
+ if (
355
+ !isValidTimestamp(rollback.attemptedAt)
356
+ || !isAutoUpdateVersion(rollback.version)
357
+ || (rollback.result !== "running" && rollback.result !== "succeeded" && rollback.result !== "failed")
358
+ ) return undefined;
359
+ return {
360
+ attemptedAt: rollback.attemptedAt,
361
+ version: rollback.version,
362
+ result: rollback.result,
363
+ };
364
+ }
365
+
366
+ export function parseAutoUpdateState(value: unknown): AutoUpdateState | null {
367
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
368
+ const raw = value as Record<string, unknown>;
369
+ const schedule = parseSchedule(raw.schedule);
370
+ if (
371
+ raw.version !== 1
372
+ || typeof raw.enabled !== "boolean"
373
+ || (raw.channel !== "latest" && raw.channel !== "preview")
374
+ || !schedule
375
+ || !isValidTimestamp(raw.createdAt)
376
+ || !isValidTimestamp(raw.updatedAt)
377
+ ) return null;
378
+
379
+ const state: AutoUpdateState = {
380
+ version: 1,
381
+ enabled: raw.enabled,
382
+ channel: raw.channel,
383
+ schedule,
384
+ createdAt: raw.createdAt,
385
+ updatedAt: raw.updatedAt,
386
+ };
387
+ if (raw.lastAttemptAt !== undefined && !isValidTimestamp(raw.lastAttemptAt)) return null;
388
+ if (raw.lastFinishedAt !== undefined && !isValidTimestamp(raw.lastFinishedAt)) return null;
389
+ if (raw.lastResult !== undefined && (
390
+ typeof raw.lastResult !== "string" || !AUTO_RESULT_VALUES.has(raw.lastResult as AutoUpdateResult)
391
+ )) return null;
392
+ if (raw.currentVersion !== undefined && !isAutoUpdateVersion(raw.currentVersion)) return null;
393
+ if (raw.targetVersion !== undefined && !isAutoUpdateVersion(raw.targetVersion)) return null;
394
+ if (raw.previousVersion !== undefined && !isAutoUpdateVersion(raw.previousVersion)) return null;
395
+ if (raw.lastErrorCode !== undefined && (
396
+ typeof raw.lastErrorCode !== "string" || !AUTO_ERROR_VALUES.has(raw.lastErrorCode as AutoUpdateErrorCode)
397
+ )) return null;
398
+ if (raw.lastJobId !== undefined && (
399
+ typeof raw.lastJobId !== "string" || raw.lastJobId.length > 100 || !/^[A-Za-z0-9._-]+$/.test(raw.lastJobId)
400
+ )) return null;
401
+ const rollback = parseRollback(raw.rollback);
402
+ if (raw.rollback !== undefined && !rollback) return null;
403
+
404
+ for (const key of ["lastAttemptAt", "lastFinishedAt", "lastResult", "currentVersion", "targetVersion", "previousVersion", "lastErrorCode", "lastJobId"] as const) {
405
+ if (raw[key] !== undefined) (state as unknown as Record<string, unknown>)[key] = raw[key];
406
+ }
407
+ if (rollback) state.rollback = rollback;
408
+ return state;
409
+ }
410
+
411
+ export function readAutoUpdateState(configDir = getConfigDir()): AutoUpdateState | null {
412
+ try {
413
+ return parseAutoUpdateState(JSON.parse(readFileSync(autoUpdateStatePath(configDir), "utf8")));
414
+ } catch {
415
+ return null;
416
+ }
417
+ }
418
+
419
+ function stateFileExists(configDir: string): boolean {
420
+ try {
421
+ return existsSync(autoUpdateStatePath(configDir));
422
+ } catch {
423
+ return false;
424
+ }
425
+ }
426
+
427
+ function ensureStateOwnership(paths: AutoUpdatePaths): void {
428
+ if (!existsSync(paths.configDir)) mkdirSync(paths.configDir, { recursive: true, mode: 0o700 });
429
+ recordOwnedConfigPath(paths.configDir, paths.statePath);
430
+ recordOwnedConfigPath(paths.configDir, paths.logPath);
431
+ recordOwnedConfigPath(paths.configDir, paths.lockPath);
432
+ recordOwnedConfigPath(paths.configDir, paths.windowsScriptPath);
433
+ recordOwnedConfigPath(paths.configDir, paths.windowsXmlPath);
434
+ }
435
+
436
+ function writeAutoUpdateState(state: AutoUpdateState, paths: AutoUpdatePaths): void {
437
+ ensureStateOwnership(paths);
438
+ atomicWriteFile(paths.statePath, `${JSON.stringify(state, null, 2)}\n`);
439
+ }
440
+
441
+ function appendAutoUpdateLog(paths: AutoUpdatePaths, line: string): void {
442
+ ensureStateOwnership(paths);
443
+ // Callers only pass fixed templates and validated versions. Do not append scheduler
444
+ // stdout/stderr: package-manager output can contain local paths and account names.
445
+ appendFileSync(paths.logPath, `${new Date().toISOString()} ${line}\n`, {
446
+ encoding: "utf8",
447
+ mode: 0o600,
448
+ });
449
+ }
450
+
451
+ function nowIso(now: () => number): string {
452
+ return new Date(now()).toISOString();
453
+ }
454
+
455
+ function defaultState(now: () => number, channel = defaultUpdateTag(currentVersion())): AutoUpdateState {
456
+ const timestamp = nowIso(now);
457
+ return {
458
+ version: 1,
459
+ enabled: true,
460
+ channel,
461
+ schedule: {
462
+ kind: "daily",
463
+ hour: AUTO_UPDATE_DEFAULT_HOUR,
464
+ minute: AUTO_UPDATE_DEFAULT_MINUTE,
465
+ },
466
+ createdAt: timestamp,
467
+ updatedAt: timestamp,
468
+ };
469
+ }
470
+
471
+ function selectedInstaller(deps: Pick<AutoUpdateSchedulerDeps, "installer">): Installer {
472
+ return deps.installer ?? detectInstall();
473
+ }
474
+
475
+ function selectedCurrentVersion(
476
+ deps: Pick<AutoUpdateSchedulerDeps, "currentVersion">,
477
+ ): string {
478
+ return deps.currentVersion ?? currentVersion();
479
+ }
480
+
481
+ function stateForChannel(
482
+ existing: AutoUpdateState | null,
483
+ channel: Channel,
484
+ now: () => number,
485
+ ): AutoUpdateState {
486
+ const base = existing ?? defaultState(now, channel);
487
+ return {
488
+ ...base,
489
+ enabled: true,
490
+ channel,
491
+ updatedAt: nowIso(now),
492
+ };
493
+ }
494
+
495
+ function stateForDisabled(
496
+ existing: AutoUpdateState | null,
497
+ now: () => number,
498
+ ): AutoUpdateState {
499
+ const base = existing ?? defaultState(now);
500
+ return {
501
+ ...base,
502
+ enabled: false,
503
+ updatedAt: nowIso(now),
504
+ lastResult: base.lastResult === "running" ? "failed" : base.lastResult,
505
+ ...(base.lastResult === "running" ? { lastErrorCode: "worker_failed" as const } : {}),
506
+ };
507
+ }
508
+
509
+ function safePathCandidate(value: string | undefined, label: string): string {
510
+ if (!value || !resolve(value) || !existsSync(resolve(value))) {
511
+ throw new AutoUpdateError(`${label} could not be resolved.`, "scheduler_unavailable");
512
+ }
513
+ return resolve(value);
514
+ }
515
+
516
+ function looksLikeNode(value: string): boolean {
517
+ const name = basename(value).toLowerCase();
518
+ return name === "node" || name === "node.exe";
519
+ }
520
+
521
+ export function resolveAutoUpdateRuntimePaths(
522
+ deps: Pick<AutoUpdateSchedulerDeps, "env" | "nodePath" | "launcherPath" | "configDir"> = {},
523
+ ): AutoUpdateRuntimePaths {
524
+ const env = deps.env ?? process.env;
525
+ const configDir = resolve(deps.configDir ?? getConfigDir());
526
+ const launcherPath = safePathCandidate(
527
+ deps.launcherPath ?? packageLauncherPath(),
528
+ "the Remodex launcher",
529
+ );
530
+ const candidates = [
531
+ deps.nodePath,
532
+ env.OCX_NODE_LAUNCHER_PATH,
533
+ env.npm_node_execpath,
534
+ looksLikeNode(process.execPath) ? process.execPath : undefined,
535
+ typeof Bun !== "undefined" ? Bun.which("node") ?? undefined : undefined,
536
+ ];
537
+ const nodePath = candidates
538
+ .filter((candidate): candidate is string => typeof candidate === "string" && candidate.length > 0)
539
+ .map(candidate => resolve(expandUserPath(candidate)))
540
+ .find(candidate => looksLikeNode(candidate) && existsSync(candidate));
541
+ if (!nodePath) {
542
+ throw new AutoUpdateError(
543
+ "A stable Node.js launcher was not found; automatic updates are not registered.",
544
+ "scheduler_unavailable",
545
+ );
546
+ }
547
+ return {
548
+ nodePath,
549
+ launcherPath,
550
+ configDir,
551
+ ...(env.CODEX_HOME?.trim() ? { codexHome: resolve(expandUserPath(env.CODEX_HOME.trim())) } : {}),
552
+ ...(env.PATH ? { path: env.PATH } : {}),
553
+ };
554
+ }
555
+
556
+ function xmlEscape(value: string): string {
557
+ return value
558
+ .replace(/&/g, "&amp;")
559
+ .replace(/</g, "&lt;")
560
+ .replace(/>/g, "&gt;")
561
+ .replace(/"/g, "&quot;")
562
+ .replace(/'/g, "&apos;");
563
+ }
564
+
565
+ function plistEscape(value: string): string {
566
+ return xmlEscape(value);
567
+ }
568
+
569
+ function batchEscape(value: string): string {
570
+ // Delayed expansion is disabled in the generated wrapper. These remaining
571
+ // metacharacters are escaped while percent signs are doubled for cmd parsing.
572
+ return value
573
+ .replace(/%/g, "%%")
574
+ .replace(/[&|<>^]/g, char => `^${char}`);
575
+ }
576
+
577
+ function batchPath(
578
+ value: string,
579
+ env: Record<string, string | undefined>,
580
+ ): string {
581
+ return windowsEnvIndirectBatchValue(value, batchEscape, env);
582
+ }
583
+
584
+ export interface AutoUpdateArtifactInput extends AutoUpdateRuntimePaths {
585
+ hour?: number;
586
+ minute?: number;
587
+ logPath?: string;
588
+ env?: Record<string, string | undefined>;
589
+ }
590
+
591
+ export function buildWindowsAutoUpdateScript(input: AutoUpdateArtifactInput): string {
592
+ const env = input.env ?? process.env;
593
+ const lines = [
594
+ "@echo off",
595
+ "setlocal DisableDelayedExpansion",
596
+ `set "OPENCODEX_HOME=${batchPath(input.configDir, env)}"`,
597
+ ...(input.codexHome ? [`set "CODEX_HOME=${batchPath(input.codexHome, env)}"`] : []),
598
+ `set "OCX_NODE_LAUNCHER_PATH=${batchPath(input.nodePath, env)}"`,
599
+ `set "RMX_AUTO_UPDATE_NODE=${batchPath(input.nodePath, env)}"`,
600
+ `set "RMX_AUTO_UPDATE_LAUNCHER=${batchPath(input.launcherPath, env)}"`,
601
+ "set \"OCX_AUTO_UPDATE=1\"",
602
+ ...(input.path ? [`set "PATH=${windowsEnvIndirectBatchPathList(input.path, batchEscape, env)}"`] : []),
603
+ "\"%RMX_AUTO_UPDATE_NODE%\" \"%RMX_AUTO_UPDATE_LAUNCHER%\" __auto-update",
604
+ 'set "RMX_AUTO_UPDATE_EXIT=%ERRORLEVEL%"',
605
+ "endlocal & exit /b %RMX_AUTO_UPDATE_EXIT%",
606
+ "",
607
+ ];
608
+ return lines.join("\r\n");
609
+ }
610
+
611
+ export function buildWindowsAutoUpdateTaskXml(
612
+ scriptPath: string,
613
+ options: {
614
+ taskName?: string;
615
+ commandPath?: string;
616
+ hour?: number;
617
+ minute?: number;
618
+ startBoundary?: string;
619
+ } = {},
620
+ ): string {
621
+ const taskName = options.taskName ?? AUTO_UPDATE_WINDOWS_TASK_NAME;
622
+ const commandPath = options.commandPath ?? "cmd.exe";
623
+ const hour = options.hour ?? AUTO_UPDATE_DEFAULT_HOUR;
624
+ const minute = options.minute ?? AUTO_UPDATE_DEFAULT_MINUTE;
625
+ const startBoundary = options.startBoundary
626
+ ?? `2000-01-01T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00`;
627
+ const argumentsValue = `/d /s /c ""${scriptPath}""`;
628
+ return `<?xml version="1.0" encoding="UTF-16"?>
629
+ <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
630
+ <RegistrationInfo>
631
+ <Author>Remodex</Author>
632
+ <Description>Daily Remodex package update check</Description>
633
+ <URI>\\${xmlEscape(taskName)}</URI>
634
+ </RegistrationInfo>
635
+ <Triggers>
636
+ <CalendarTrigger>
637
+ <StartBoundary>${xmlEscape(startBoundary)}</StartBoundary>
638
+ <Enabled>true</Enabled>
639
+ <ScheduleByDay>
640
+ <DaysInterval>1</DaysInterval>
641
+ </ScheduleByDay>
642
+ </CalendarTrigger>
643
+ </Triggers>
644
+ <Principals>
645
+ <Principal id="Author">
646
+ <LogonType>InteractiveToken</LogonType>
647
+ <RunLevel>LeastPrivilege</RunLevel>
648
+ </Principal>
649
+ </Principals>
650
+ <Settings>
651
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
652
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
653
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
654
+ <AllowHardTerminate>true</AllowHardTerminate>
655
+ <StartWhenAvailable>true</StartWhenAvailable>
656
+ <ExecutionTimeLimit>PT15M</ExecutionTimeLimit>
657
+ <Hidden>true</Hidden>
658
+ </Settings>
659
+ <Actions Context="Author">
660
+ <Exec>
661
+ <Command>${xmlEscape(commandPath)}</Command>
662
+ <Arguments>${xmlEscape(argumentsValue)}</Arguments>
663
+ <WorkingDirectory>${xmlEscape(dirname(scriptPath))}</WorkingDirectory>
664
+ </Exec>
665
+ </Actions>
666
+ </Task>
667
+ `;
668
+ }
669
+
670
+ function plistArray(values: readonly string[]): string {
671
+ return values.map(value => ` <string>${plistEscape(value)}</string>`).join("\n");
672
+ }
673
+
674
+ function plistEnvironment(entries: Readonly<Record<string, string>>): string {
675
+ return Object.entries(entries)
676
+ .map(([key, value]) => ` <key>${plistEscape(key)}</key>\n <string>${plistEscape(value)}</string>`)
677
+ .join("\n");
678
+ }
679
+
680
+ export function buildLaunchdAutoUpdatePlist(
681
+ input: AutoUpdateArtifactInput & { label?: string },
682
+ ): string {
683
+ const hour = input.hour ?? AUTO_UPDATE_DEFAULT_HOUR;
684
+ const minute = input.minute ?? AUTO_UPDATE_DEFAULT_MINUTE;
685
+ const label = input.label ?? AUTO_UPDATE_LAUNCHD_LABEL;
686
+ const environment: Record<string, string> = {
687
+ OPENCODEX_HOME: input.configDir,
688
+ OCX_AUTO_UPDATE: "1",
689
+ OCX_NODE_LAUNCHER_PATH: input.nodePath,
690
+ };
691
+ if (input.codexHome) environment.CODEX_HOME = input.codexHome;
692
+ if (input.path) environment.PATH = input.path;
693
+ return `<?xml version="1.0" encoding="UTF-8"?>
694
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
695
+ <plist version="1.0">
696
+ <dict>
697
+ <key>Label</key>
698
+ <string>${plistEscape(label)}</string>
699
+ <key>ProgramArguments</key>
700
+ <array>
701
+ ${plistArray([input.nodePath, input.launcherPath, "__auto-update"])}
702
+ </array>
703
+ <key>EnvironmentVariables</key>
704
+ <dict>
705
+ ${plistEnvironment(environment)}
706
+ </dict>
707
+ <key>StartCalendarInterval</key>
708
+ <dict>
709
+ <key>Hour</key>
710
+ <integer>${hour}</integer>
711
+ <key>Minute</key>
712
+ <integer>${minute}</integer>
713
+ </dict>
714
+ <key>RunAtLoad</key>
715
+ <false/>
716
+ <key>KeepAlive</key>
717
+ <false/>
718
+ <key>ProcessType</key>
719
+ <string>Background</string>
720
+ <key>ThrottleInterval</key>
721
+ <integer>3600</integer>
722
+ <key>StandardOutPath</key>
723
+ <string>/dev/null</string>
724
+ <key>StandardErrorPath</key>
725
+ <string>/dev/null</string>
726
+ </dict>
727
+ </plist>
728
+ `;
729
+ }
730
+
731
+ function systemdQuote(value: string): string {
732
+ return `"${value
733
+ .replace(/\\/g, "\\\\")
734
+ .replace(/"/g, "\\\"")
735
+ .replace(/\n/g, "\\n")
736
+ .replace(/%/g, "%%")}"`;
737
+ }
738
+
739
+ function systemdEnvironment(name: string, value: string): string {
740
+ return `Environment=${systemdQuote(`${name}=${value}`)}`;
741
+ }
742
+
743
+ export function buildSystemdAutoUpdateService(
744
+ input: AutoUpdateArtifactInput & { serviceName?: string; timerName?: string },
745
+ ): string {
746
+ const serviceName = input.serviceName ?? AUTO_UPDATE_SYSTEMD_SERVICE;
747
+ const timerName = input.timerName ?? AUTO_UPDATE_SYSTEMD_TIMER;
748
+ const env = [
749
+ systemdEnvironment("OPENCODEX_HOME", input.configDir),
750
+ systemdEnvironment("OCX_AUTO_UPDATE", "1"),
751
+ systemdEnvironment("OCX_NODE_LAUNCHER_PATH", input.nodePath),
752
+ ...(input.codexHome ? [systemdEnvironment("CODEX_HOME", input.codexHome)] : []),
753
+ ...(input.path ? [systemdEnvironment("PATH", input.path)] : []),
754
+ ];
755
+ return `[Unit]
756
+ Description=Remodex automatic package update
757
+ After=network-online.target
758
+ Wants=network-online.target
759
+
760
+ [Service]
761
+ Type=oneshot
762
+ ExecStart=${systemdQuote(input.nodePath)} ${systemdQuote(input.launcherPath)} __auto-update
763
+ WorkingDirectory=${systemdQuote(input.configDir)}
764
+ ${env.join("\n")}
765
+ TimeoutStartSec=15min
766
+
767
+ [Install]
768
+ WantedBy=default.target
769
+ # Timer: ${timerName}
770
+ `;
771
+ }
772
+
773
+ export function buildSystemdAutoUpdateTimer(
774
+ input: Pick<AutoUpdateArtifactInput, "hour" | "minute"> & { serviceName?: string },
775
+ ): string {
776
+ const hour = String(input.hour ?? AUTO_UPDATE_DEFAULT_HOUR).padStart(2, "0");
777
+ const minute = String(input.minute ?? AUTO_UPDATE_DEFAULT_MINUTE).padStart(2, "0");
778
+ const serviceName = input.serviceName ?? AUTO_UPDATE_SYSTEMD_SERVICE;
779
+ return `[Unit]
780
+ Description=Daily Remodex automatic update trigger
781
+
782
+ [Timer]
783
+ OnCalendar=*-*-* ${hour}:${minute}:00
784
+ Persistent=true
785
+ AccuracySec=1min
786
+ Unit=${serviceName}
787
+
788
+ [Install]
789
+ WantedBy=timers.target
790
+ `;
791
+ }
792
+
793
+ function defaultRunCommand(file: string, args: readonly string[]): AutoUpdateCommandResult {
794
+ const result = spawnSync(file, [...args], {
795
+ encoding: "utf8",
796
+ stdio: ["ignore", "pipe", "pipe"],
797
+ timeout: 30_000,
798
+ windowsHide: true,
799
+ });
800
+ return {
801
+ status: result.status,
802
+ stdout: typeof result.stdout === "string" ? result.stdout.slice(0, 2000) : "",
803
+ stderr: typeof result.stderr === "string" ? result.stderr.slice(0, 2000) : "",
804
+ };
805
+ }
806
+
807
+ function commandRunner(deps: AutoUpdateSchedulerDeps): AutoUpdateCommandRunner {
808
+ return deps.runCommand ?? defaultRunCommand;
809
+ }
810
+
811
+ function writeTextFile(path: string, content: string, encoding: BufferEncoding = "utf8"): void {
812
+ const dir = dirname(path);
813
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
814
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
815
+ writeFileSyncCompat(temporary, content, encoding);
816
+ try {
817
+ renameSync(temporary, path);
818
+ } catch (error) {
819
+ try { unlinkSync(temporary); } catch { /* best effort */ }
820
+ throw error;
821
+ }
822
+ }
823
+
824
+ function writeFileSyncCompat(path: string, content: string, encoding: BufferEncoding): void {
825
+ // Kept as a tiny wrapper so all scheduler artifacts use one atomic write path.
826
+ writeFileSync(path, content, { encoding, mode: 0o600 });
827
+ }
828
+
829
+ function removeExactFile(path: string): void {
830
+ try {
831
+ if (existsSync(path)) unlinkSync(path);
832
+ } catch (error) {
833
+ throw new AutoUpdateError("An automatic-update scheduler artifact could not be removed.", "scheduler_registration_failed");
834
+ }
835
+ }
836
+
837
+ function runChecked(
838
+ run: AutoUpdateCommandRunner,
839
+ file: string,
840
+ args: readonly string[],
841
+ ): AutoUpdateCommandResult {
842
+ const result = run(file, args);
843
+ if (result.status !== 0) {
844
+ throw new AutoUpdateError("The platform scheduler command failed.", "scheduler_registration_failed");
845
+ }
846
+ return result;
847
+ }
848
+
849
+ function windowsTaskProbe(
850
+ deps: AutoUpdateSchedulerDeps,
851
+ taskName = AUTO_UPDATE_WINDOWS_TASK_NAME,
852
+ ): AutoSchedulerPresence {
853
+ if (deps.probeWindows) return deps.probeWindows();
854
+ // Production uses the existing locale-aware, fail-closed Task Scheduler probe.
855
+ if (!deps.runCommand && (deps.platform ?? process.platform) === "win32") {
856
+ try {
857
+ return probeWindowsSchedulerTask(taskName).status;
858
+ } catch {
859
+ return "unknown";
860
+ }
861
+ }
862
+ const file = deps.schtasksPath ?? "schtasks.exe";
863
+ const result = commandRunner(deps)(file, ["/Query", "/TN", taskName]);
864
+ if (result.status === 0) return "present";
865
+ // The scheduler returns the same exit code for "not found" and access denied.
866
+ // A test/adapter may provide a localized-safe marker in stdout; otherwise fail closed.
867
+ const text = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.toLowerCase();
868
+ if (text.includes("not exist") || text.includes("cannot find") || text.includes("no task")) return "absent";
869
+ return "unknown";
870
+ }
871
+
872
+ function launchdDomain(uid: number): string {
873
+ return `gui/${uid}`;
874
+ }
875
+
876
+ function launchdProbe(
877
+ deps: AutoUpdateSchedulerDeps,
878
+ label = AUTO_UPDATE_LAUNCHD_LABEL,
879
+ ): AutoSchedulerPresence {
880
+ if (deps.probeLaunchd) return deps.probeLaunchd();
881
+ const file = deps.launchctlPath ?? "/bin/launchctl";
882
+ const uid = deps.uid ?? process.getuid?.() ?? 0;
883
+ const result = commandRunner(deps)(file, ["print", `${launchdDomain(uid)}/${label}`]);
884
+ if (result.status === 0) return "present";
885
+ if (result.status === 113) return "absent";
886
+ return "unknown";
887
+ }
888
+
889
+ function systemdProbe(
890
+ deps: AutoUpdateSchedulerDeps,
891
+ timerName = AUTO_UPDATE_SYSTEMD_TIMER,
892
+ paths = autoUpdatePaths(deps),
893
+ ): AutoSchedulerPresence {
894
+ if (deps.probeSystemd) return deps.probeSystemd();
895
+ if (!existsSync(paths.systemdTimerPath)) return "absent";
896
+ const file = deps.systemctlPath ?? "systemctl";
897
+ const result = commandRunner(deps)(file, ["--user", "is-enabled", timerName]);
898
+ if (result.status === 0) return "present";
899
+ const text = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.toLowerCase();
900
+ if (text.includes("not-found") || text.includes("not found")) return "absent";
901
+ return "unknown";
902
+ }
903
+
904
+ function schedulerPresence(
905
+ deps: AutoUpdateSchedulerDeps,
906
+ paths = autoUpdatePaths(deps),
907
+ ): AutoSchedulerPresence {
908
+ const platform = deps.platform ?? process.platform;
909
+ if (platform === "win32") return windowsTaskProbe(deps);
910
+ if (platform === "darwin") return launchdProbe(deps);
911
+ if (platform === "linux") return systemdProbe(deps, AUTO_UPDATE_SYSTEMD_TIMER, paths);
912
+ return "unknown";
913
+ }
914
+
915
+ function windowsCommandPath(): string {
916
+ return "cmd.exe";
917
+ }
918
+
919
+ function registerWindowsScheduler(
920
+ deps: AutoUpdateSchedulerDeps,
921
+ paths: AutoUpdatePaths,
922
+ runtime: AutoUpdateRuntimePaths,
923
+ schedule: AutoUpdateSchedule,
924
+ ): void {
925
+ const run = commandRunner(deps);
926
+ const before = windowsTaskProbe(deps);
927
+ if (before === "unknown") {
928
+ throw new AutoUpdateError("Task Scheduler could not be queried safely.", "scheduler_query_failed");
929
+ }
930
+ writeTextFile(
931
+ paths.windowsScriptPath,
932
+ buildWindowsAutoUpdateScript({
933
+ ...runtime,
934
+ hour: schedule.hour,
935
+ minute: schedule.minute,
936
+ logPath: paths.logPath,
937
+ env: deps.env ?? process.env,
938
+ }),
939
+ );
940
+ const xml = buildWindowsAutoUpdateTaskXml(paths.windowsScriptPath, {
941
+ hour: schedule.hour,
942
+ minute: schedule.minute,
943
+ commandPath: windowsCommandPath(),
944
+ });
945
+ writeTextFileUtf16(paths.windowsXmlPath, `\uFEFF${xml}`);
946
+
947
+ const schtasks = deps.schtasksPath ?? (process.platform === "win32"
948
+ ? resolveTrustedWindowsSchtasksExe()
949
+ : "schtasks.exe");
950
+ runChecked(run, schtasks, ["/Create", "/TN", AUTO_UPDATE_WINDOWS_TASK_NAME, "/XML", paths.windowsXmlPath, "/F"]);
951
+ const after = windowsTaskProbe(deps);
952
+ if (after !== "present") {
953
+ throw new AutoUpdateError("Task Scheduler registration could not be verified.", "scheduler_registration_failed");
954
+ }
955
+ }
956
+
957
+ function writeTextFileUtf16(path: string, content: string): void {
958
+ const dir = dirname(path);
959
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
960
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
961
+ writeFileSync(temporary, content, { encoding: "utf16le", mode: 0o600 });
962
+ try {
963
+ renameSync(temporary, path);
964
+ } catch (error) {
965
+ try { unlinkSync(temporary); } catch { /* best effort */ }
966
+ throw error;
967
+ }
968
+ }
969
+
970
+ function unregisterWindowsScheduler(
971
+ deps: AutoUpdateSchedulerDeps,
972
+ paths: AutoUpdatePaths,
973
+ ): void {
974
+ const run = commandRunner(deps);
975
+ const before = windowsTaskProbe(deps);
976
+ if (before === "unknown") {
977
+ throw new AutoUpdateError("Task Scheduler could not be queried safely.", "scheduler_query_failed");
978
+ }
979
+ if (before === "present") {
980
+ const schtasks = deps.schtasksPath ?? (process.platform === "win32"
981
+ ? resolveTrustedWindowsSchtasksExe()
982
+ : "schtasks.exe");
983
+ runChecked(run, schtasks, ["/Delete", "/TN", AUTO_UPDATE_WINDOWS_TASK_NAME, "/F"]);
984
+ if (windowsTaskProbe(deps) !== "absent") {
985
+ throw new AutoUpdateError("Task Scheduler removal could not be verified.", "scheduler_registration_failed");
986
+ }
987
+ }
988
+ removeExactFile(paths.windowsScriptPath);
989
+ removeExactFile(paths.windowsXmlPath);
990
+ }
991
+
992
+ function registerLaunchdScheduler(
993
+ deps: AutoUpdateSchedulerDeps,
994
+ paths: AutoUpdatePaths,
995
+ runtime: AutoUpdateRuntimePaths,
996
+ schedule: AutoUpdateSchedule,
997
+ ): void {
998
+ const run = commandRunner(deps);
999
+ const dir = dirname(paths.launchdPlistPath);
1000
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
1001
+ writeTextFile(
1002
+ paths.launchdPlistPath,
1003
+ buildLaunchdAutoUpdatePlist({
1004
+ ...runtime,
1005
+ hour: schedule.hour,
1006
+ minute: schedule.minute,
1007
+ logPath: paths.logPath,
1008
+ }),
1009
+ );
1010
+ const launchctl = deps.launchctlPath ?? "/bin/launchctl";
1011
+ const uid = deps.uid ?? process.getuid?.() ?? 0;
1012
+ const domain = launchdDomain(uid);
1013
+ // bootout is intentionally best effort: the job may not have existed yet.
1014
+ run(launchctl, ["bootout", `${domain}/${AUTO_UPDATE_LAUNCHD_LABEL}`]);
1015
+ runChecked(run, launchctl, ["bootstrap", domain, paths.launchdPlistPath]);
1016
+ if (launchdProbe(deps) !== "present") {
1017
+ throw new AutoUpdateError("launchd registration could not be verified.", "scheduler_registration_failed");
1018
+ }
1019
+ }
1020
+
1021
+ function unregisterLaunchdScheduler(
1022
+ deps: AutoUpdateSchedulerDeps,
1023
+ paths: AutoUpdatePaths,
1024
+ ): void {
1025
+ const run = commandRunner(deps);
1026
+ const launchctl = deps.launchctlPath ?? "/bin/launchctl";
1027
+ const uid = deps.uid ?? process.getuid?.() ?? 0;
1028
+ const domain = launchdDomain(uid);
1029
+ const before = launchdProbe(deps);
1030
+ if (before === "unknown") {
1031
+ throw new AutoUpdateError("launchd could not be queried safely.", "scheduler_query_failed");
1032
+ }
1033
+ if (before === "present") {
1034
+ const result = run(launchctl, ["bootout", `${domain}/${AUTO_UPDATE_LAUNCHD_LABEL}`]);
1035
+ if (result.status !== 0 && launchdProbe(deps) !== "absent") {
1036
+ throw new AutoUpdateError("launchd removal could not be verified.", "scheduler_registration_failed");
1037
+ }
1038
+ }
1039
+ if (launchdProbe(deps) === "unknown") {
1040
+ throw new AutoUpdateError("launchd removal could not be verified.", "scheduler_query_failed");
1041
+ }
1042
+ removeExactFile(paths.launchdPlistPath);
1043
+ }
1044
+
1045
+ function ensureSystemdAvailable(
1046
+ deps: AutoUpdateSchedulerDeps,
1047
+ run: AutoUpdateCommandRunner,
1048
+ ): string {
1049
+ const systemctl = deps.systemctlPath ?? "systemctl";
1050
+ const probe = run(systemctl, ["--user", "show-environment"]);
1051
+ if (probe.status !== 0) {
1052
+ throw new AutoUpdateError("The systemd user manager is unavailable.", "scheduler_unavailable");
1053
+ }
1054
+ return systemctl;
1055
+ }
1056
+
1057
+ function registerSystemdScheduler(
1058
+ deps: AutoUpdateSchedulerDeps,
1059
+ paths: AutoUpdatePaths,
1060
+ runtime: AutoUpdateRuntimePaths,
1061
+ schedule: AutoUpdateSchedule,
1062
+ ): void {
1063
+ const run = commandRunner(deps);
1064
+ const systemctl = ensureSystemdAvailable(deps, run);
1065
+ const dir = dirname(paths.systemdServicePath);
1066
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
1067
+ writeTextFile(
1068
+ paths.systemdServicePath,
1069
+ buildSystemdAutoUpdateService({
1070
+ ...runtime,
1071
+ hour: schedule.hour,
1072
+ minute: schedule.minute,
1073
+ logPath: paths.logPath,
1074
+ }),
1075
+ );
1076
+ writeTextFile(
1077
+ paths.systemdTimerPath,
1078
+ buildSystemdAutoUpdateTimer({
1079
+ hour: schedule.hour,
1080
+ minute: schedule.minute,
1081
+ }),
1082
+ );
1083
+ runChecked(run, systemctl, ["--user", "daemon-reload"]);
1084
+ runChecked(run, systemctl, ["--user", "enable", "--now", AUTO_UPDATE_SYSTEMD_TIMER]);
1085
+ if (systemdProbe(deps, AUTO_UPDATE_SYSTEMD_TIMER, paths) !== "present") {
1086
+ throw new AutoUpdateError("systemd timer registration could not be verified.", "scheduler_registration_failed");
1087
+ }
1088
+ }
1089
+
1090
+ function unregisterSystemdScheduler(
1091
+ deps: AutoUpdateSchedulerDeps,
1092
+ paths: AutoUpdatePaths,
1093
+ ): void {
1094
+ const run = commandRunner(deps);
1095
+ const systemctl = deps.systemctlPath ?? "systemctl";
1096
+ const filesExist = existsSync(paths.systemdServicePath) || existsSync(paths.systemdTimerPath);
1097
+ if (filesExist) ensureSystemdAvailable(deps, run);
1098
+ const before = systemdProbe(deps, AUTO_UPDATE_SYSTEMD_TIMER, paths);
1099
+ if (before === "unknown") {
1100
+ throw new AutoUpdateError("systemd timer could not be queried safely.", "scheduler_query_failed");
1101
+ }
1102
+ if (before === "present") {
1103
+ runChecked(run, systemctl, ["--user", "disable", "--now", AUTO_UPDATE_SYSTEMD_TIMER]);
1104
+ runChecked(run, systemctl, ["--user", "daemon-reload"]);
1105
+ if (systemdProbe(deps, AUTO_UPDATE_SYSTEMD_TIMER, paths) === "unknown") {
1106
+ throw new AutoUpdateError("systemd timer removal could not be verified.", "scheduler_query_failed");
1107
+ }
1108
+ }
1109
+ removeExactFile(paths.systemdTimerPath);
1110
+ removeExactFile(paths.systemdServicePath);
1111
+ try { run(systemctl, ["--user", "daemon-reload"]); } catch { /* best effort */ }
1112
+ }
1113
+
1114
+ function registerScheduler(
1115
+ deps: AutoUpdateSchedulerDeps,
1116
+ paths: AutoUpdatePaths,
1117
+ runtime: AutoUpdateRuntimePaths,
1118
+ schedule: AutoUpdateSchedule,
1119
+ ): void {
1120
+ const platform = deps.platform ?? process.platform;
1121
+ if (platform === "win32") registerWindowsScheduler(deps, paths, runtime, schedule);
1122
+ else if (platform === "darwin") registerLaunchdScheduler(deps, paths, runtime, schedule);
1123
+ else if (platform === "linux") registerSystemdScheduler(deps, paths, runtime, schedule);
1124
+ else throw new AutoUpdateError(`Automatic updates are unsupported on ${platform}.`, "unsupported_platform");
1125
+ }
1126
+
1127
+ function unregisterScheduler(deps: AutoUpdateSchedulerDeps, paths: AutoUpdatePaths): void {
1128
+ const platform = deps.platform ?? process.platform;
1129
+ if (platform === "win32") unregisterWindowsScheduler(deps, paths);
1130
+ else if (platform === "darwin") unregisterLaunchdScheduler(deps, paths);
1131
+ else if (platform === "linux") unregisterSystemdScheduler(deps, paths);
1132
+ else throw new AutoUpdateError(`Automatic updates are unsupported on ${platform}.`, "unsupported_platform");
1133
+ }
1134
+
1135
+ export function enableAutoUpdates(
1136
+ requestedChannel?: Channel,
1137
+ deps: AutoUpdateSchedulerDeps = {},
1138
+ ): AutoUpdateState {
1139
+ if (requestedChannel !== undefined && !isAutoUpdateChannel(requestedChannel)) {
1140
+ throw new AutoUpdateError(
1141
+ "The automatic-update channel must be latest or preview.",
1142
+ "invalid_channel",
1143
+ );
1144
+ }
1145
+ const platform = deps.platform ?? process.platform;
1146
+ const installer = selectedInstaller(deps);
1147
+ if (installer === "source") {
1148
+ throw new AutoUpdateError(
1149
+ "Automatic updates require a global npm or Bun installation, not a source checkout.",
1150
+ "source_checkout",
1151
+ );
1152
+ }
1153
+ if (!isSupportedPlatform(platform)) {
1154
+ throw new AutoUpdateError(`Automatic updates are unsupported on ${platform}.`, "unsupported_platform");
1155
+ }
1156
+ const now = deps.now ?? Date.now;
1157
+ const paths = autoUpdatePaths(deps);
1158
+ const existing = readAutoUpdateState(paths.configDir);
1159
+ if (stateFileExists(paths.configDir) && !existing) {
1160
+ throw new AutoUpdateError("The automatic-update state file is invalid; repair it before enabling updates.", "invalid_state");
1161
+ }
1162
+ const channel = requestedChannel ?? existing?.channel ?? defaultUpdateTag(selectedCurrentVersion(deps));
1163
+ const next = stateForChannel(existing, channel, now);
1164
+ const runtime = resolveAutoUpdateRuntimePaths({
1165
+ env: deps.env,
1166
+ nodePath: deps.nodePath,
1167
+ launcherPath: deps.launcherPath,
1168
+ configDir: paths.configDir,
1169
+ });
1170
+ // Initialize ownership while the config root is still empty. Platform
1171
+ // registration writes scheduler artifacts before the state file is persisted;
1172
+ // if those artifacts land first in a fresh root, the ownership layer
1173
+ // intentionally refuses to claim the now-nonempty directory.
1174
+ ensureStateOwnership(paths);
1175
+ registerScheduler(deps, paths, runtime, next.schedule);
1176
+ try {
1177
+ writeAutoUpdateState(next, paths);
1178
+ appendAutoUpdateLog(paths, `scheduler enabled (${next.channel}, daily ${String(next.schedule.hour).padStart(2, "0")}:${String(next.schedule.minute).padStart(2, "0")})`);
1179
+ } catch (error) {
1180
+ // Do not leave a newly-created scheduler active without a state record. The
1181
+ // cleanup is exact-path-only and best effort; the original write error remains
1182
+ // the user-facing failure.
1183
+ try { unregisterScheduler(deps, paths); } catch { /* preserve primary failure */ }
1184
+ throw error;
1185
+ }
1186
+ return next;
1187
+ }
1188
+
1189
+ export function disableAutoUpdates(
1190
+ deps: AutoUpdateSchedulerDeps = {},
1191
+ ): AutoUpdateState {
1192
+ const now = deps.now ?? Date.now;
1193
+ const paths = autoUpdatePaths(deps);
1194
+ const existing = readAutoUpdateState(paths.configDir);
1195
+ if (stateFileExists(paths.configDir) && !existing) {
1196
+ throw new AutoUpdateError("The automatic-update state file is invalid; repair it before disabling updates.", "invalid_state");
1197
+ }
1198
+ unregisterScheduler(deps, paths);
1199
+ const next = stateForDisabled(existing, now);
1200
+ writeAutoUpdateState(next, paths);
1201
+ appendAutoUpdateLog(paths, "scheduler disabled");
1202
+ return next;
1203
+ }
1204
+
1205
+ export function readAutoUpdateStatus(
1206
+ deps: AutoUpdateSchedulerDeps = {},
1207
+ ): AutoUpdateStatus {
1208
+ const platform = deps.platform ?? process.platform;
1209
+ const paths = autoUpdatePaths(deps);
1210
+ const installer = selectedInstaller(deps);
1211
+ const stored = readAutoUpdateState(paths.configDir);
1212
+ const defaultEnabled = installer !== "source" && isSupportedPlatform(platform);
1213
+ const effective = stored ?? defaultState(
1214
+ deps.now ?? Date.now,
1215
+ defaultUpdateTag(selectedCurrentVersion(deps)),
1216
+ );
1217
+ return {
1218
+ enabled: stored?.enabled ?? defaultEnabled,
1219
+ defaultEnabled,
1220
+ supported: isSupportedPlatform(platform) && installer !== "source",
1221
+ installer,
1222
+ channel: effective.channel,
1223
+ schedule: effective.schedule,
1224
+ schedulerKind: autoUpdateSchedulerKind(platform),
1225
+ scheduler: isSupportedPlatform(platform) ? schedulerPresence(deps, paths) : "unknown",
1226
+ state: stored,
1227
+ paths,
1228
+ };
1229
+ }
1230
+
1231
+ export function formatAutoUpdateStatus(status: AutoUpdateStatus): string[] {
1232
+ const lines = [
1233
+ `Automatic updates: ${status.enabled ? "enabled" : "disabled"}`,
1234
+ `Default: ${status.defaultEnabled ? "enabled for this installation" : "disabled"}`,
1235
+ `Installer: ${status.installer}`,
1236
+ `Channel: ${status.channel}`,
1237
+ `Schedule: daily at ${String(status.schedule.hour).padStart(2, "0")}:${String(status.schedule.minute).padStart(2, "0")} (local time)`,
1238
+ `Scheduler: ${status.schedulerKind ?? "unsupported"} (${status.scheduler})`,
1239
+ `State: ${status.paths.statePath}`,
1240
+ `Log: ${status.paths.logPath}`,
1241
+ ];
1242
+ if (status.installer === "source") lines.push("Enablement requires a global npm or Bun installation.");
1243
+ if (status.scheduler === "unknown") lines.push("Scheduler registration could not be verified.");
1244
+ if (status.state?.lastResult) lines.push(`Last result: ${status.state.lastResult}`);
1245
+ if (status.state?.currentVersion) lines.push(`Last known version: ${status.state.currentVersion}`);
1246
+ if (status.state?.targetVersion) lines.push(`Last target: ${status.state.targetVersion}`);
1247
+ if (status.state?.rollback) {
1248
+ lines.push(`Rollback: ${status.state.rollback.result} to v${status.state.rollback.version}`);
1249
+ }
1250
+ if (status.state?.lastErrorCode) lines.push(`Last error: ${status.state.lastErrorCode}`);
1251
+ return lines;
1252
+ }
1253
+
1254
+ /**
1255
+ * Provision the default scheduler during a normal bootstrap. It deliberately
1256
+ * does not run for the proxy's service child or for the updater itself.
1257
+ */
1258
+ export function ensureDefaultAutoUpdateScheduler(
1259
+ deps: AutoUpdateSchedulerDeps = {},
1260
+ ): AutoUpdateState | null {
1261
+ const env = deps.env ?? process.env;
1262
+ if (env.OCX_SERVICE === "1" || env.OCX_AUTO_UPDATE === "1") return null;
1263
+ const platform = deps.platform ?? process.platform;
1264
+ const installer = selectedInstaller(deps);
1265
+ if (installer === "source" || !isSupportedPlatform(platform)) return null;
1266
+ const paths = autoUpdatePaths(deps);
1267
+ const existing = readAutoUpdateState(paths.configDir);
1268
+ if (existing?.enabled === false) return existing;
1269
+ if (stateFileExists(paths.configDir) && !existing) return null;
1270
+
1271
+ let presence: AutoSchedulerPresence;
1272
+ try {
1273
+ presence = schedulerPresence(deps, paths);
1274
+ } catch {
1275
+ return null;
1276
+ }
1277
+ if (presence === "present") {
1278
+ if (existing) return existing;
1279
+ const now = deps.now ?? Date.now;
1280
+ const next = defaultState(now, defaultUpdateTag(selectedCurrentVersion(deps)));
1281
+ try {
1282
+ writeAutoUpdateState(next, paths);
1283
+ appendAutoUpdateLog(paths, `scheduler enabled by default (${next.channel})`);
1284
+ return next;
1285
+ } catch {
1286
+ return null;
1287
+ }
1288
+ }
1289
+ if (presence === "unknown") return null;
1290
+ try {
1291
+ return enableAutoUpdates(existing?.channel, deps);
1292
+ } catch {
1293
+ // Default enablement must never prevent the proxy from starting. An explicit
1294
+ // `rmx system update auto on` surfaces the same error to the operator.
1295
+ return null;
1296
+ }
1297
+ }
1298
+
1299
+ export interface AutoUpdateLock {
1300
+ release(): void;
1301
+ }
1302
+
1303
+ function lockRecord(now: number): string {
1304
+ return JSON.stringify({ version: 1, pid: process.pid, startedAt: new Date(now).toISOString() });
1305
+ }
1306
+
1307
+ function lockIsReclaimable(
1308
+ path: string,
1309
+ now: number,
1310
+ isAlive: (pid: number) => boolean,
1311
+ ): boolean {
1312
+ let age = 0;
1313
+ try {
1314
+ age = Math.max(0, now - statSync(path).mtimeMs);
1315
+ } catch {
1316
+ return false;
1317
+ }
1318
+ if (age < AUTO_UPDATE_LOCK_STALE_MS) return false;
1319
+ try {
1320
+ const value = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
1321
+ const pid = value.pid;
1322
+ if (Number.isSafeInteger(pid) && Number(pid) > 0) return !isAlive(Number(pid));
1323
+ return age >= AUTO_UPDATE_LOCK_MALFORMED_STALE_MS;
1324
+ } catch {
1325
+ return age >= AUTO_UPDATE_LOCK_MALFORMED_STALE_MS;
1326
+ }
1327
+ }
1328
+
1329
+ export function tryAcquireAutoUpdateLock(options: {
1330
+ path?: string;
1331
+ now?: () => number;
1332
+ isAlive?: (pid: number) => boolean;
1333
+ } = {}): AutoUpdateLock | null {
1334
+ const path = options.path ?? autoUpdateLockPath();
1335
+ const now = options.now ?? Date.now;
1336
+ const isAlive = options.isAlive ?? isProcessAlive;
1337
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1338
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1339
+ let fd: number;
1340
+ try {
1341
+ fd = openSync(path, "wx", 0o600);
1342
+ } catch (error) {
1343
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") return null;
1344
+ if (attempt === 0 && lockIsReclaimable(path, now(), isAlive)) {
1345
+ try { unlinkSync(path); } catch { return null; }
1346
+ continue;
1347
+ }
1348
+ return null;
1349
+ }
1350
+ try {
1351
+ writeSync(fd, lockRecord(now()));
1352
+ } catch {
1353
+ try { closeSync(fd); } catch { /* best effort */ }
1354
+ try { unlinkSync(path); } catch { /* best effort */ }
1355
+ return null;
1356
+ }
1357
+ try { closeSync(fd); } catch { /* best effort */ }
1358
+ let released = false;
1359
+ return {
1360
+ release() {
1361
+ if (released) return;
1362
+ released = true;
1363
+ try { unlinkSync(path); } catch { /* another run may have recovered it */ }
1364
+ },
1365
+ };
1366
+ }
1367
+ return null;
1368
+ }
1369
+
1370
+ function readInstalledVersionFromPackageRoot(packageRoot = INSTALLED_PACKAGE_ROOT): string | null {
1371
+ try {
1372
+ const packagePath = join(packageRoot, "package.json");
1373
+ const value = JSON.parse(readFileSync(packagePath, "utf8")) as Record<string, unknown>;
1374
+ return isAutoUpdateVersion(value.version) ? value.version : null;
1375
+ } catch {
1376
+ return null;
1377
+ }
1378
+ }
1379
+
1380
+ function newAutoJobId(prefix = "auto"): string {
1381
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
1382
+ }
1383
+
1384
+ function updateState(
1385
+ state: AutoUpdateState,
1386
+ patch: Partial<AutoUpdateState>,
1387
+ paths: AutoUpdatePaths,
1388
+ ): AutoUpdateState {
1389
+ const next: AutoUpdateState = {
1390
+ ...state,
1391
+ ...patch,
1392
+ updatedAt: new Date().toISOString(),
1393
+ };
1394
+ writeAutoUpdateState(next, paths);
1395
+ return next;
1396
+ }
1397
+
1398
+ function activeManualUpdateExists(
1399
+ readJob: (jobId?: string | null) => UpdateJobState | null,
1400
+ isAlive: (pid: number) => boolean,
1401
+ now: number,
1402
+ ): boolean {
1403
+ const job = readJob();
1404
+ if (!job || (job.status !== "running" && job.status !== "restarting")) return false;
1405
+ return staleActiveUpdateJobReason(job, now, isAlive) === null;
1406
+ }
1407
+
1408
+ async function defaultHealthProbe(): Promise<boolean> {
1409
+ return (await findLiveProxy()) !== null;
1410
+ }
1411
+
1412
+ function checkResultWithExactTarget(
1413
+ check: UpdateCheckResult,
1414
+ target: string,
1415
+ ): UpdateCheckResult {
1416
+ return {
1417
+ ...check,
1418
+ latestVersion: target,
1419
+ updateAvailable: true,
1420
+ canUpdate: true,
1421
+ reason: undefined,
1422
+ };
1423
+ }
1424
+
1425
+ async function runGuiWorker(
1426
+ deps: AutoUpdateWorkerDeps,
1427
+ jobId: string,
1428
+ channel: Channel,
1429
+ check: UpdateCheckResult,
1430
+ integrity: ReturnType<typeof checkUpdatePackageIntegrity>,
1431
+ target: string,
1432
+ ): Promise<UpdateJobState | null> {
1433
+ const runner = deps.runGuiWorkerFn ?? (
1434
+ (id, selectedChannel, restart, io) => runGuiUpdateWorker(id, selectedChannel, restart, io)
1435
+ );
1436
+ await runner(jobId, channel, true, {
1437
+ checkForUpdateFn: () => check,
1438
+ integrityFn: () => integrity,
1439
+ exactVersion: target,
1440
+ });
1441
+ return (deps.readUpdateJobFn ?? readUpdateJob)(jobId);
1442
+ }
1443
+
1444
+ async function attemptRollback(
1445
+ state: AutoUpdateState,
1446
+ paths: AutoUpdatePaths,
1447
+ check: UpdateCheckResult,
1448
+ previousVersion: string,
1449
+ deps: AutoUpdateWorkerDeps,
1450
+ ): Promise<boolean> {
1451
+ const now = deps.now ?? Date.now;
1452
+ const attemptedAt = nowIso(now);
1453
+ let next = updateState(state, {
1454
+ rollback: { attemptedAt, version: previousVersion, result: "running" },
1455
+ lastErrorCode: "rollback_failed",
1456
+ }, paths);
1457
+ appendAutoUpdateLog(paths, `rollback started to v${previousVersion}`);
1458
+
1459
+ const integrity = (deps.integrityFn ?? ((version: string) => checkUpdatePackageIntegrity(version)))(previousVersion);
1460
+ if (integrity.ok !== true) {
1461
+ next = updateState(next, {
1462
+ rollback: { attemptedAt, version: previousVersion, result: "failed" },
1463
+ lastErrorCode: "rollback_integrity_unavailable",
1464
+ lastFinishedAt: nowIso(now),
1465
+ lastResult: "failed",
1466
+ }, paths);
1467
+ appendAutoUpdateLog(paths, `rollback refused because v${previousVersion} integrity could not be verified`);
1468
+ return false;
1469
+ }
1470
+
1471
+ const rollbackCheck = checkResultWithExactTarget(check, previousVersion);
1472
+ const rollbackJobId = newAutoJobId("rollback");
1473
+ try {
1474
+ const job = await runGuiWorker(deps, rollbackJobId, check.channel, rollbackCheck, integrity, previousVersion);
1475
+ const healthy = await (deps.healthFn ?? defaultHealthProbe)();
1476
+ const ok = job?.status === "succeeded" && healthy;
1477
+ next = updateState(next, {
1478
+ rollback: { attemptedAt, version: previousVersion, result: ok ? "succeeded" : "failed" },
1479
+ lastErrorCode: ok ? "update_failed" : "rollback_failed",
1480
+ lastFinishedAt: nowIso(now),
1481
+ lastResult: "failed",
1482
+ currentVersion: previousVersion,
1483
+ targetVersion: check.latestVersion ?? previousVersion,
1484
+ lastJobId: rollbackJobId,
1485
+ }, paths);
1486
+ appendAutoUpdateLog(paths, ok
1487
+ ? `rollback completed to v${previousVersion}; health check passed`
1488
+ : `rollback failed for v${previousVersion}; health check did not pass`);
1489
+ return ok;
1490
+ } catch {
1491
+ updateState(next, {
1492
+ rollback: { attemptedAt, version: previousVersion, result: "failed" },
1493
+ lastErrorCode: "rollback_failed",
1494
+ lastFinishedAt: nowIso(now),
1495
+ lastResult: "failed",
1496
+ lastJobId: rollbackJobId,
1497
+ }, paths);
1498
+ appendAutoUpdateLog(paths, `rollback worker failed for v${previousVersion}`);
1499
+ return false;
1500
+ }
1501
+ }
1502
+
1503
+ export async function runAutomaticUpdateWorker(
1504
+ deps: AutoUpdateWorkerDeps = {},
1505
+ ): Promise<AutoUpdateRunResult> {
1506
+ const paths = autoUpdatePaths({ configDir: deps.configDir });
1507
+ const now = deps.now ?? Date.now;
1508
+ const installer = deps.installer ?? detectInstall();
1509
+ const current = (deps.currentVersionFn ?? currentVersion)();
1510
+ const currentSafe = isAutoUpdateVersion(current) ? current : "?";
1511
+ const state = readAutoUpdateState(paths.configDir);
1512
+ if (stateFileExists(paths.configDir) && !state) {
1513
+ appendAutoUpdateLog(paths, "worker stopped because auto-update state is invalid");
1514
+ return { ok: false, result: "failed", currentVersion: currentSafe, targetVersion: null, rolledBack: false };
1515
+ }
1516
+ if (installer === "source") {
1517
+ return { ok: false, result: "skipped", currentVersion: currentSafe, targetVersion: null, rolledBack: false };
1518
+ }
1519
+ const effective = state ?? defaultState(now);
1520
+ if (!effective.enabled) {
1521
+ return { ok: true, result: "skipped", currentVersion: currentSafe, targetVersion: null, rolledBack: false };
1522
+ }
1523
+ const lock = tryAcquireAutoUpdateLock({ path: paths.lockPath, now, isAlive: deps.isProcessAliveFn });
1524
+ if (!lock) {
1525
+ const busy = updateState(effective, {
1526
+ lastResult: "busy",
1527
+ lastFinishedAt: nowIso(now),
1528
+ lastErrorCode: undefined,
1529
+ }, paths);
1530
+ appendAutoUpdateLog(paths, "worker skipped because another update is already running");
1531
+ return { ok: true, result: busy.lastResult ?? "busy", currentVersion: currentSafe, targetVersion: null, rolledBack: false };
1532
+ }
1533
+
1534
+ try {
1535
+ const readJob = deps.readUpdateJobFn ?? readUpdateJob;
1536
+ if (activeManualUpdateExists(readJob, deps.isProcessAliveFn ?? isProcessAlive, now())) {
1537
+ updateState(effective, {
1538
+ lastResult: "busy",
1539
+ lastFinishedAt: nowIso(now),
1540
+ lastErrorCode: undefined,
1541
+ }, paths);
1542
+ appendAutoUpdateLog(paths, "worker skipped because a manual update job is active");
1543
+ return { ok: true, result: "busy", currentVersion: currentSafe, targetVersion: null, rolledBack: false };
1544
+ }
1545
+
1546
+ const channel = effective.channel;
1547
+ const check = (deps.checkForUpdateFn ?? ((selected: Channel) => checkForUpdate(selected)))(channel);
1548
+ const target = check.latestVersion;
1549
+ if (!isAutoUpdateVersion(target)) {
1550
+ updateState(effective, {
1551
+ lastResult: "failed",
1552
+ lastFinishedAt: nowIso(now),
1553
+ currentVersion: currentSafe === "?" ? undefined : currentSafe,
1554
+ targetVersion: undefined,
1555
+ lastErrorCode: "update_unavailable",
1556
+ }, paths);
1557
+ appendAutoUpdateLog(paths, "registry did not return a valid update version");
1558
+ return { ok: false, result: "failed", currentVersion: currentSafe, targetVersion: null, rolledBack: false };
1559
+ }
1560
+ if (target === current || !check.updateAvailable || !check.canUpdate) {
1561
+ updateState(effective, {
1562
+ lastResult: "already_current",
1563
+ lastFinishedAt: nowIso(now),
1564
+ currentVersion: currentSafe === "?" ? undefined : currentSafe,
1565
+ targetVersion: target,
1566
+ lastErrorCode: undefined,
1567
+ }, paths);
1568
+ appendAutoUpdateLog(paths, `already current at v${target}`);
1569
+ return { ok: true, result: "already_current", currentVersion: currentSafe, targetVersion: target, rolledBack: false };
1570
+ }
1571
+
1572
+ const integrity = (deps.integrityFn ?? ((version: string) => checkUpdatePackageIntegrity(version)))(target);
1573
+ if (integrity.ok !== true) {
1574
+ updateState(effective, {
1575
+ lastResult: "failed",
1576
+ lastFinishedAt: nowIso(now),
1577
+ currentVersion: currentSafe === "?" ? undefined : currentSafe,
1578
+ targetVersion: target,
1579
+ lastErrorCode: integrity.ok === false ? "integrity_invalid" : "integrity_unavailable",
1580
+ }, paths);
1581
+ appendAutoUpdateLog(paths, `v${target} was not installed because integrity could not be verified`);
1582
+ return { ok: false, result: "failed", currentVersion: currentSafe, targetVersion: target, rolledBack: false };
1583
+ }
1584
+
1585
+ const runningState = updateState(effective, {
1586
+ lastAttemptAt: nowIso(now),
1587
+ lastResult: "running",
1588
+ currentVersion: currentSafe === "?" ? undefined : currentSafe,
1589
+ previousVersion: currentSafe === "?" ? undefined : currentSafe,
1590
+ targetVersion: target,
1591
+ lastErrorCode: undefined,
1592
+ rollback: undefined,
1593
+ lastJobId: undefined,
1594
+ }, paths);
1595
+ appendAutoUpdateLog(paths, `verified v${target}; update started`);
1596
+
1597
+ const jobId = newAutoJobId();
1598
+ const exactCheck = checkResultWithExactTarget(check, target);
1599
+ let job: UpdateJobState | null = null;
1600
+ try {
1601
+ job = await runGuiWorker(deps, jobId, channel, exactCheck, integrity, target);
1602
+ } catch {
1603
+ job = (deps.readUpdateJobFn ?? readUpdateJob)(jobId);
1604
+ }
1605
+
1606
+ const runtimeVersion = deps.installedVersionFn
1607
+ ? deps.installedVersionFn()
1608
+ : readInstalledVersionFromPackageRoot();
1609
+ const packageChanged = runtimeVersion !== null && runtimeVersion !== current;
1610
+ const workerSucceeded = job?.status === "succeeded";
1611
+ const healthy = workerSucceeded && await (deps.healthFn ?? defaultHealthProbe)();
1612
+ if (workerSucceeded && healthy && (runtimeVersion === null || runtimeVersion === target)) {
1613
+ updateState(runningState, {
1614
+ lastResult: "updated",
1615
+ lastFinishedAt: nowIso(now),
1616
+ currentVersion: target,
1617
+ targetVersion: target,
1618
+ previousVersion: currentSafe === "?" ? undefined : currentSafe,
1619
+ lastJobId: jobId,
1620
+ lastErrorCode: undefined,
1621
+ }, paths);
1622
+ appendAutoUpdateLog(paths, `updated to v${target}; health check passed`);
1623
+ return { ok: true, result: "updated", currentVersion: target, targetVersion: target, rolledBack: false };
1624
+ }
1625
+
1626
+ // A successful worker with an unreadable package root still replaced files in
1627
+ // practice, so a failed health check must not silently skip recovery. When the
1628
+ // version is readable, any non-current version (including an unexpected target)
1629
+ // is concrete evidence that rollback is required.
1630
+ const shouldRollback = currentSafe !== "?"
1631
+ && (packageChanged || (workerSucceeded && runtimeVersion === null));
1632
+ if (!shouldRollback) {
1633
+ updateState(runningState, {
1634
+ lastResult: "failed",
1635
+ lastFinishedAt: nowIso(now),
1636
+ lastErrorCode: workerSucceeded ? "health_failed" : "update_failed",
1637
+ lastJobId: jobId,
1638
+ }, paths);
1639
+ appendAutoUpdateLog(paths, workerSucceeded
1640
+ ? `v${target} installed but health check failed`
1641
+ : `update worker failed for v${target}`);
1642
+ return { ok: false, result: "failed", currentVersion: currentSafe, targetVersion: target, rolledBack: false };
1643
+ }
1644
+
1645
+ const rolledBack = await attemptRollback(
1646
+ runningState,
1647
+ paths,
1648
+ exactCheck,
1649
+ currentSafe,
1650
+ deps,
1651
+ );
1652
+ return {
1653
+ ok: rolledBack,
1654
+ result: "failed",
1655
+ currentVersion: rolledBack ? currentSafe : (runtimeVersion ?? target),
1656
+ targetVersion: target,
1657
+ rolledBack,
1658
+ };
1659
+ } catch (error) {
1660
+ const latest = readAutoUpdateState(paths.configDir) ?? effective;
1661
+ updateState(latest, {
1662
+ lastResult: "failed",
1663
+ lastFinishedAt: nowIso(now),
1664
+ lastErrorCode: error instanceof AutoUpdateError ? error.code : "worker_failed",
1665
+ }, paths);
1666
+ appendAutoUpdateLog(paths, "automatic update worker failed");
1667
+ return { ok: false, result: "failed", currentVersion: currentSafe, targetVersion: latest.targetVersion ?? null, rolledBack: false };
1668
+ } finally {
1669
+ lock.release();
1670
+ }
1671
+ }