relay-companion 0.1.69 → 0.1.70

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/auto-update.js +391 -41
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.69",
3
+ "version": "0.1.70",
4
4
  "description": "Relay companion for ordinary messages, with dormant coordination features available only by explicit opt-in.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,16 +1,27 @@
1
1
  // Companion self-update.
2
2
  //
3
- // The companion ships as an npm package (`relay-companion`) run by two launchd
4
- // agents (work.relay.companion = daemon, work.relay.companion.pill = overlay),
5
- // both pointing at ABSOLUTE paths inside the global install. Publishing a new
6
- // version to npm is the production deploy — but nothing pulled it onto users'
7
- // machines, so a click-routing fix (or any fix) only reached people who manually
8
- // re-ran `npm i -g relay-companion@latest`.
3
+ // The companion ships as an npm package (`relay-companion`) run by two always-on
4
+ // services — on macOS two launchd agents (work.relay.companion = daemon,
5
+ // work.relay.companion.pill = overlay), on Windows two Scheduled Tasks ("Relay
6
+ // Companion Daemon" / "Relay Companion Pill") — all pointing at ABSOLUTE paths
7
+ // inside the npm-managed install. Publishing a new version to npm is the
8
+ // production deploy — but nothing pulled it onto users' machines, so a
9
+ // click-routing fix (or any fix) only reached people who manually re-ran
10
+ // `npm i -g relay-companion@latest`.
9
11
  //
10
12
  // This closes that loop: the daemon (the single always-on owner, kept alive by
11
- // launchd KeepAlive) checks npm's latest dist-tag, installs that exact version into
12
- // the prefix which owns the running package, verifies/repairs the desktop surfaces,
13
- // and reloads both launchd services from their refreshed plists.
13
+ // launchd KeepAlive / Task Scheduler) checks npm's latest dist-tag, installs that
14
+ // exact version into the prefix which owns the running package, verifies the
15
+ // freshly written tree, and restarts both services (pill first, daemon last).
16
+ //
17
+ // macOS restart hand-off: the detached updater shell DESCENDS from the daemon's
18
+ // launchd job. Booting out an ancestor job orphans the shell's Mach bootstrap
19
+ // session, after which every `launchctl bootstrap` fails with EIO ("Bootstrap
20
+ // failed: 5: Input/output error"), stranding BOTH services unloaded (observed in
21
+ // the field on 0.1.68). The restart phase is therefore submitted to launchd as an
22
+ // independent one-shot job (`launchctl submit`) that does not descend from the
23
+ // daemon, verifies both services are listed afterwards, retries once, and rolls
24
+ // back to the backed-up tree when verification fails.
14
25
 
15
26
  import os from "node:os";
16
27
  import path from "node:path";
@@ -26,6 +37,9 @@ import {
26
37
  export const PACKAGE_NAME = "relay-companion";
27
38
  const DAEMON_LABEL = "work.relay.companion";
28
39
  const PILL_LABEL = "work.relay.companion.pill";
40
+ // Must match the Scheduled Task names registered by src/install.js.
41
+ const WINDOWS_DAEMON_TASK_NAME = "Relay Companion Daemon";
42
+ const WINDOWS_PILL_TASK_NAME = "Relay Companion Pill";
29
43
  const DEFAULT_REGISTRY = "https://registry.npmjs.org";
30
44
  // A production publish should reach an idle machine promptly. The registry request is
31
45
  // a tiny dist-tags document and the daemon is the only checker, so one minute gives us
@@ -75,10 +89,41 @@ export function isManagedInstall(packageRoot) {
75
89
  }
76
90
 
77
91
  // Resolve the npm prefix which OWNS this exact package tree. Global npm installs live
78
- // at <prefix>/lib/node_modules; Relay's no-sudo fallback lives at
79
- // <prefix>/node_modules. Passing the explicit prefix to npm prevents an nvm/fnm/volta
80
- // daemon from accidentally updating Homebrew's copy (or the reverse).
81
- export function managedInstallInfo(packageRoot, { home = os.homedir() } = {}) {
92
+ // at <prefix>/lib/node_modules (macOS/Linux) or <prefix>\node_modules (Windows);
93
+ // Relay's no-sudo fallback lives at <home>/.relay/lib/node_modules. Passing the
94
+ // explicit prefix to npm prevents an nvm/fnm/volta daemon from accidentally updating
95
+ // Homebrew's copy (or the reverse).
96
+ export function managedInstallInfo(
97
+ packageRoot,
98
+ { home = os.homedir(), platform = process.platform, exists = fs.existsSync } = {},
99
+ ) {
100
+ if (platform === "win32") {
101
+ // Decide with path.win32 so the same logic is exact both on a real Windows
102
+ // daemon and when unit tests exercise it from POSIX. Windows paths are
103
+ // case-insensitive; compare accordingly.
104
+ const win = path.win32;
105
+ const root = win.resolve(String(packageRoot || ""));
106
+ const nmSuffix = win.sep + win.join("node_modules", PACKAGE_NAME);
107
+ if (!root.toLowerCase().endsWith(nmSuffix.toLowerCase())) return null;
108
+ const prefix = root.slice(0, root.length - nmSuffix.length) || win.parse(root).root;
109
+ const relayLocalSuffix = win.sep + win.join(".relay", "lib");
110
+ if (prefix.toLowerCase().endsWith(relayLocalSuffix.toLowerCase())) {
111
+ return { packageRoot: root, prefix, global: false };
112
+ }
113
+ // A Windows global prefix has the same <dir>\node_modules\<pkg> shape as an
114
+ // arbitrary project checkout, which is NOT a safe rollback unit (the project
115
+ // hoists Relay's dependencies beside it). Only adopt a prefix that carries
116
+ // npm's global bin shims: `npm i -g relay-companion` always writes
117
+ // <prefix>\relay.cmd, and custom prefixes that own npm itself carry npm.cmd /
118
+ // node_modules\npm. The darwin branch refuses arbitrary node_modules for the
119
+ // same reason.
120
+ for (const marker of ["relay.cmd", "npm.cmd", win.join("node_modules", "npm")]) {
121
+ try {
122
+ if (exists(win.join(prefix, marker))) return { packageRoot: root, prefix, global: true };
123
+ } catch {}
124
+ }
125
+ return null;
126
+ }
82
127
  const root = path.resolve(String(packageRoot || ""));
83
128
  const globalSuffix = path.join("lib", "node_modules", PACKAGE_NAME);
84
129
  const localSuffix = path.join("node_modules", PACKAGE_NAME);
@@ -191,6 +236,207 @@ export function runtimeNpmDir(execPath = process.execPath) {
191
236
  return null;
192
237
  }
193
238
 
239
+ // Build the Windows (win32) update script: a single PowerShell program with the same
240
+ // guarantees as the darwin shell script — single-writer pid-aware lock, local backup
241
+ // of the running tree, exact-version install + verification BEFORE any restart,
242
+ // Scheduled Task restart (pill first, daemon last), and restore-on-any-failure.
243
+ // It is spawned detached via `powershell.exe -EncodedCommand` (base64 UTF-16LE),
244
+ // which sidesteps every powershell/cmd quoting layer for the multi-line script.
245
+ export function buildWindowsUpdateScript({
246
+ home = os.homedir(),
247
+ currentVersion = null,
248
+ targetVersion = null,
249
+ packageRoot = companionPackageRoot(),
250
+ execPath = process.execPath,
251
+ logPath = path.win32.join(home, ".relay", "update.log"),
252
+ lockPath = path.win32.join(home, ".relay", "update.lock"),
253
+ exists = fs.existsSync,
254
+ } = {}) {
255
+ const install = managedInstallInfo(packageRoot, { home, platform: "win32", exists });
256
+ if (!install) throw new Error(`cannot update unmanaged package root: ${packageRoot}`);
257
+ // Safe single-quote for PowerShell literals: inside '...' the only escape is ''.
258
+ const pq = (s) => `'${String(s).replaceAll("'", "''")}'`;
259
+ // Restore target if the new install is broken: reinstall the version we're running
260
+ // now so the on-disk tree stays loadable for the next Scheduled Task launch.
261
+ const restore = parseVersion(currentVersion) ? currentVersion : "latest";
262
+ const target = parseVersion(targetVersion) ? String(targetVersion) : null;
263
+ const installTarget = target || "latest";
264
+ const installFlags = install.global ? "-g " : "";
265
+ const win = path.win32;
266
+ // Prefer the npm.cmd that sits beside the runtime node.exe which owns the running
267
+ // install (nvm-windows/volta), mirroring darwin's runtime-PATH prepend. The install
268
+ // command separately pins the owning prefix, so an unusual npm config still cannot
269
+ // redirect the update to another copy.
270
+ const execDir = execPath ? win.dirname(String(execPath)) : "";
271
+ const execBase = execPath ? win.basename(String(execPath)).toLowerCase() : "";
272
+ let runtimeDir = null;
273
+ if (execDir && execDir !== "." && !execBase.includes("electron")) {
274
+ try {
275
+ if (exists(win.join(execDir, "npm.cmd")) || exists(win.join(execDir, "npm"))) runtimeDir = execDir;
276
+ } catch {}
277
+ }
278
+ const npmInstallLine =
279
+ `npm install ${installFlags}--prefix "${install.prefix}" "${PACKAGE_NAME}@${installTarget}"` +
280
+ " --prefer-online --no-audit --no-fund --no-save --package-lock=false" +
281
+ " --fetch-retries=3 --fetch-retry-mintimeout=1000 --fetch-retry-maxtimeout=10000";
282
+ const lines = [
283
+ `$ErrorActionPreference = 'Continue'`,
284
+ `$Log = ${pq(logPath)}`,
285
+ `$Lock = ${pq(lockPath)}`,
286
+ `$Prefix = ${pq(install.prefix)}`,
287
+ `$Pkg = ${pq(install.packageRoot)}`,
288
+ `$PkgParent = Split-Path -Parent $Pkg`,
289
+ // Global npm on Windows nests Relay's dependencies inside the package directory,
290
+ // making it a complete rollback unit. The no-sudo fallback hoists dependencies
291
+ // into the Relay-owned node_modules, so that whole directory is the unit there.
292
+ install.global ? `$LiveTree = $Pkg` : `$LiveTree = Join-Path $Prefix 'node_modules'`,
293
+ install.global
294
+ ? `$Backup = Join-Path $PkgParent '.relay-companion-update-backup'`
295
+ : `$Backup = Join-Path $Prefix '.relay-companion-node_modules-update-backup'`,
296
+ `$Node = ${pq(execPath || "node.exe")}`,
297
+ `$ComSpec = if ($env:ComSpec) { $env:ComSpec } else { Join-Path $env:SystemRoot 'System32\\cmd.exe' }`,
298
+ `$NodeCmd = if (Test-Path -LiteralPath $Node) { '"' + $Node + '"' } else { 'node' }`,
299
+ // Relay's own postinstall would repair/restart surfaces mid-update; this script
300
+ // owns the verify + restart sequencing itself (matches the darwin script).
301
+ `$env:RELAY_SKIP_DESKTOP_POSTINSTALL = '1'`,
302
+ ...(runtimeDir ? [`$env:Path = ${pq(`${runtimeDir};`)} + $env:Path`] : []),
303
+ `function Write-UpdateLog([string]$Message) {`,
304
+ ` try { Add-Content -LiteralPath $Log -Value ('[relay-update] ' + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' ' + $Message) } catch {}`,
305
+ `}`,
306
+ // cmd /d /s /c resolves npm.cmd/schtasks through PATH exactly like a console
307
+ // would, and appends all native output to the update log, mirroring the darwin
308
+ // script's ">> LOG 2>&1" on every native step. Windows PowerShell 5.1 passes the
309
+ // argument with inner quotes verbatim, which is precisely the /s /c contract.
310
+ `function Invoke-Native([string]$CommandLine) {`,
311
+ ` & $ComSpec /d /s /c ($CommandLine + ' >> "' + $Log + '" 2>&1')`,
312
+ ` return $LASTEXITCODE`,
313
+ `}`,
314
+ `function Restore-Tree {`,
315
+ ` if (Test-Path -LiteralPath $Backup) {`,
316
+ ` Remove-Item -LiteralPath $LiveTree -Recurse -Force -ErrorAction SilentlyContinue`,
317
+ ` Move-Item -LiteralPath $Backup -Destination $LiveTree -Force -ErrorAction SilentlyContinue`,
318
+ ` Write-UpdateLog 'restored previous package tree'`,
319
+ ` }`,
320
+ `}`,
321
+ `$null = New-Item -ItemType Directory -Force -Path (Split-Path -Parent $Lock) -ErrorAction SilentlyContinue`,
322
+ // Never steal from a live slow install. A recorded dead owner is recoverable
323
+ // immediately; an ownerless lock is stolen only when truly stale (>20 minutes).
324
+ `if (Test-Path -LiteralPath $Lock) {`,
325
+ ` $LockPidRaw = $null`,
326
+ ` try { $LockPidRaw = Get-Content -LiteralPath (Join-Path $Lock 'pid') -TotalCount 1 -ErrorAction Stop } catch {}`,
327
+ ` if ($null -ne $LockPidRaw -and ('' + $LockPidRaw).Trim() -ne '') {`,
328
+ ` $OwnerAlive = $false`,
329
+ ` try { $OwnerAlive = [bool](Get-Process -Id ([int](('' + $LockPidRaw).Trim())) -ErrorAction Stop) } catch { $OwnerAlive = $false }`,
330
+ ` if (-not $OwnerAlive) { Remove-Item -LiteralPath $Lock -Recurse -Force -ErrorAction SilentlyContinue }`,
331
+ ` } else {`,
332
+ ` $LockItem = $null`,
333
+ ` try { $LockItem = Get-Item -LiteralPath $Lock -ErrorAction Stop } catch {}`,
334
+ ` if ($LockItem -and ((Get-Date) - $LockItem.LastWriteTime).TotalMinutes -gt 20) { Remove-Item -LiteralPath $Lock -Recurse -Force -ErrorAction SilentlyContinue }`,
335
+ ` }`,
336
+ `}`,
337
+ // Single-writer: New-Item -ItemType Directory WITHOUT -Force is CreateDirectory —
338
+ // atomic, and fails when the directory already exists (= mkdir lock semantics).
339
+ `try { $null = New-Item -ItemType Directory -Path $Lock -ErrorAction Stop } catch { Write-UpdateLog 'another update in progress; skipping'; exit 0 }`,
340
+ `$KeepNew = $false`,
341
+ // PowerShell runs `finally` on normal completion, thrown errors, and `exit`,
342
+ // covering the same paths as the darwin EXIT/INT/TERM trap.
343
+ `try {`,
344
+ ` try { Set-Content -LiteralPath (Join-Path $Lock 'pid') -Value ([string]$PID) -ErrorAction Stop } catch {}`,
345
+ // Sweep trash a previous commit could not delete (a locked file keeps a renamed
346
+ // backup dir alive; see the commit step below).
347
+ ` try { Get-ChildItem -LiteralPath (Split-Path -Parent $Backup) -Force -Filter ((Split-Path -Leaf $Backup) + '.trash-*') -ErrorAction Stop | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue } catch {}`,
348
+ // A hard kill bypasses `finally` and can leave both the known-good backup and a
349
+ // partially written live tree. The backup always wins.
350
+ ` if (Test-Path -LiteralPath $Backup) {`,
351
+ ` Write-UpdateLog 'recovering interrupted prior update'`,
352
+ ` Remove-Item -LiteralPath $LiveTree -Recurse -Force -ErrorAction SilentlyContinue`,
353
+ ` Move-Item -LiteralPath $Backup -Destination $LiveTree -Force -ErrorAction SilentlyContinue`,
354
+ ` if (Test-Path -LiteralPath $Backup) { Write-UpdateLog 'could not restore interrupted backup; aborting safely'; exit 1 }`,
355
+ ` }`,
356
+ // Same-volume directory rename: atomic, and legal on Windows even while files
357
+ // inside are open/executing (delete is what NTFS blocks, not rename).
358
+ ` Move-Item -LiteralPath $LiveTree -Destination $Backup -Force -ErrorAction SilentlyContinue`,
359
+ ` if ((Test-Path -LiteralPath $LiveTree) -or -not (Test-Path -LiteralPath $Backup)) { Write-UpdateLog ('could not back up ' + $LiveTree + '; aborting safely'); exit 1 }`,
360
+ ` Write-UpdateLog ('installing ${PACKAGE_NAME}@${installTarget} into ' + $Pkg + ' (current ${restore})')`,
361
+ ` $InstallExit = Invoke-Native ${pq(npmInstallLine)}`,
362
+ ` if ($InstallExit -ne 0) { Write-UpdateLog 'install FAILED; restoring ${restore} from local backup'; exit 1 }`,
363
+ // Electron lives nested under the package (global layout) or hoisted beside it
364
+ // (Relay's local-prefix layout). Repair a skipped binary download before verify.
365
+ ` $ElectronDir = $null`,
366
+ ` foreach ($Candidate in @((Join-Path $Pkg 'node_modules\\electron'), (Join-Path $PkgParent 'electron'))) {`,
367
+ ` if (Test-Path -LiteralPath (Join-Path $Candidate 'package.json')) { $ElectronDir = $Candidate; break }`,
368
+ ` }`,
369
+ ` $Electron = $null`,
370
+ ` if ($ElectronDir) { $Electron = Join-Path $ElectronDir 'dist\\electron.exe' }`,
371
+ ` if ($ElectronDir -and -not (Test-Path -LiteralPath $Electron) -and (Test-Path -LiteralPath (Join-Path $ElectronDir 'install.js'))) {`,
372
+ ` Write-UpdateLog 'electron runtime missing; running electron install.js'`,
373
+ ` $null = Invoke-Native ($NodeCmd + ' "' + (Join-Path $ElectronDir 'install.js') + '"')`,
374
+ ` }`,
375
+ // Verify the package at the exact Task-Scheduler-owned root reached the exact
376
+ // discovered version. A mismatched prefix or stale npm response must never
377
+ // trigger a restart.
378
+ ` $InstalledVersion = $null`,
379
+ ` try { $InstalledVersion = (Get-Content -LiteralPath (Join-Path $Pkg 'package.json') -Raw -ErrorAction Stop | ConvertFrom-Json).version } catch {}`,
380
+ ` Write-UpdateLog ('installed version=' + $InstalledVersion + ' at ' + $Pkg)`,
381
+ target
382
+ ? ` $VersionOk = $false; if ($InstalledVersion -ceq '${target}') { $VersionOk = $true } else { Write-UpdateLog ('verify FAILED: installed ' + $InstalledVersion + ' != exact target ${target}') }`
383
+ : ` $VersionOk = $true`,
384
+ ` $VerifyOk = $false`,
385
+ ` if ($VersionOk -and $Electron -and (Test-Path -LiteralPath $Electron) -and (Test-Path -LiteralPath (Join-Path $Pkg 'bin\\relay.js'))) { $VerifyOk = $true }`,
386
+ ` if (-not $VerifyOk) { Write-UpdateLog 'post-install verify FAILED; restoring ${restore} from local backup'; exit 1 }`,
387
+ // Unlike macOS there is no repair-desktop step: the Scheduled Task actions embed
388
+ // only update-stable absolute paths (node.exe, bin\relay.js, electron.exe live at
389
+ // the same locations inside the refreshed tree), so re-registration is unneeded —
390
+ // and would /End + /Run tasks outside this script's careful sequencing.
391
+ ` Write-UpdateLog 'install verified; restarting services (pill first, daemon last)'`,
392
+ ` $null = Invoke-Native 'schtasks /End /TN "${WINDOWS_PILL_TASK_NAME}"'`,
393
+ ` $PillRun = Invoke-Native 'schtasks /Run /TN "${WINDOWS_PILL_TASK_NAME}"'`,
394
+ ` if ($PillRun -ne 0) {`,
395
+ ` Write-UpdateLog 'pill restart FAILED; restoring previous tree'`,
396
+ // End the (possibly half-started) new pill BEFORE restoring: Windows cannot
397
+ // delete a tree whose executables are running.
398
+ ` $null = Invoke-Native 'schtasks /End /TN "${WINDOWS_PILL_TASK_NAME}"'`,
399
+ ` Restore-Tree`,
400
+ ` $null = Invoke-Native 'schtasks /Run /TN "${WINDOWS_PILL_TASK_NAME}"'`,
401
+ ` exit 1`,
402
+ ` }`,
403
+ // /End on the daemon task may terminate THIS script too (Task Scheduler ends the
404
+ // action's process tree; node's detached spawn does not breakaway from it). The
405
+ // watcher below is created via CIM, so it is parented to the WMI provider — OUT of
406
+ // that tree — and re-runs the daemon task even if this script dies between the
407
+ // /End and the /Run. When this script survives, the extra /Run is a harmless
408
+ // "already running" no-op appended to the log.
409
+ ` try {`,
410
+ ` $WatchCmd = 'ping -n 16 127.0.0.1 >nul & schtasks /Run /TN "${WINDOWS_DAEMON_TASK_NAME}" >> "' + $Log + '" 2>&1'`,
411
+ ` $Startup = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0 } -ErrorAction Stop`,
412
+ ` $null = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ('"' + $ComSpec + '" /d /s /c "' + $WatchCmd + '"'); ProcessStartupInformation = $Startup } -ErrorAction Stop`,
413
+ ` } catch { Write-UpdateLog 'daemon revival watcher unavailable; relying on direct schtasks restart' }`,
414
+ ` $null = Invoke-Native 'schtasks /End /TN "${WINDOWS_DAEMON_TASK_NAME}"'`,
415
+ ` $DaemonRun = Invoke-Native 'schtasks /Run /TN "${WINDOWS_DAEMON_TASK_NAME}"'`,
416
+ ` if ($DaemonRun -ne 0) {`,
417
+ ` Write-UpdateLog 'daemon restart FAILED; restoring previous tree'`,
418
+ ` $null = Invoke-Native 'schtasks /End /TN "${WINDOWS_PILL_TASK_NAME}"'`,
419
+ ` Restore-Tree`,
420
+ ` $null = Invoke-Native 'schtasks /Run /TN "${WINDOWS_PILL_TASK_NAME}"'`,
421
+ ` $null = Invoke-Native 'schtasks /Run /TN "${WINDOWS_DAEMON_TASK_NAME}"'`,
422
+ ` exit 1`,
423
+ ` }`,
424
+ ` $KeepNew = $true`,
425
+ // Rename-then-delete: if a leftover locked file blocks deletion, the rename has
426
+ // already moved the dir OUT of the backup name, so a later "recovering
427
+ // interrupted prior update" can never restore a stale tree over a good one.
428
+ ` $BackupTrash = $Backup + '.trash-' + $PID`,
429
+ ` try { Move-Item -LiteralPath $Backup -Destination $BackupTrash -Force -ErrorAction Stop } catch { $BackupTrash = $Backup }`,
430
+ ` Remove-Item -LiteralPath $BackupTrash -Recurse -Force -ErrorAction SilentlyContinue`,
431
+ ` Write-UpdateLog 'update committed; pill and daemon restarted from the new tree'`,
432
+ `} finally {`,
433
+ ` if (-not $KeepNew) { Restore-Tree }`,
434
+ ` Remove-Item -LiteralPath $Lock -Recurse -Force -ErrorAction SilentlyContinue`,
435
+ `}`,
436
+ ];
437
+ return lines.join("\n");
438
+ }
439
+
194
440
  export function spawnDetachedUpdate({
195
441
  home = os.homedir(),
196
442
  uid = typeof process.getuid === "function" ? process.getuid() : 0,
@@ -199,18 +445,52 @@ export function spawnDetachedUpdate({
199
445
  packageRoot = companionPackageRoot(),
200
446
  execPath = process.execPath,
201
447
  launchctlPath = "/bin/launchctl",
448
+ platform = process.platform,
202
449
  logPath = path.join(home, ".relay", "update.log"),
203
450
  lockPath = path.join(home, ".relay", "update.lock"),
204
451
  spawnImpl = spawn,
205
452
  log = () => {},
206
453
  ensureDir = true,
207
454
  mode = DEFAULT_COMPANION_MODE,
455
+ exists = fs.existsSync,
208
456
  } = {}) {
209
457
  if (ensureDir) {
210
458
  try {
211
459
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
212
460
  } catch {}
213
461
  }
462
+ // A detached child with NO 'error' listener turns an async spawn failure (EMFILE,
463
+ // EAGAIN, ENOENT on the shell binary) into an unhandled 'error' that would crash
464
+ // this always-on daemon. Attach the listener BEFORE unref, then release the child
465
+ // so it survives this process being killed by the restart it performs.
466
+ const attachAndRelease = (child) => {
467
+ if (child && typeof child.on === "function") {
468
+ child.on("error", (err) => log(`auto-update spawn error: ${err && err.message ? err.message : String(err)}`));
469
+ }
470
+ if (child && typeof child.unref === "function") child.unref();
471
+ return child;
472
+ };
473
+ if (platform === "win32") {
474
+ const script = buildWindowsUpdateScript({
475
+ home,
476
+ currentVersion,
477
+ targetVersion,
478
+ packageRoot,
479
+ execPath,
480
+ logPath,
481
+ lockPath,
482
+ exists,
483
+ });
484
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
485
+ const systemRoot = process.env.SystemRoot || process.env.windir || "C:\\Windows";
486
+ const powershellPath = path.win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
487
+ const child = spawnImpl(
488
+ powershellPath,
489
+ ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded],
490
+ { detached: true, stdio: "ignore", windowsHide: true },
491
+ );
492
+ return attachAndRelease(child);
493
+ }
214
494
  const q = (s) => `'${String(s).replaceAll("'", "'\\''")}'`; // safe single-quote for the shell
215
495
  const LOG = q(logPath);
216
496
  const LOCK = q(lockPath);
@@ -230,8 +510,66 @@ export function spawnDetachedUpdate({
230
510
  const installTarget = target || "latest";
231
511
  const installFlags = install.global ? "-g " : "";
232
512
  const launchAgentsDir = path.join(home, "Library", "LaunchAgents");
513
+ const pillPlistPath = path.join(launchAgentsDir, `${PILL_LABEL}.plist`);
514
+ const daemonPlistPath = path.join(launchAgentsDir, `${DAEMON_LABEL}.plist`);
515
+ const liveTreePath = install.global ? install.packageRoot : path.join(install.prefix, "node_modules");
516
+ const backupPath = install.global
517
+ ? path.join(path.dirname(install.packageRoot), ".relay-companion-update-backup")
518
+ : path.join(install.prefix, ".relay-companion-node_modules-update-backup");
233
519
  const npmInstall = (version) =>
234
520
  `RELAY_SKIP_DESKTOP_POSTINSTALL=1 npm install ${installFlags}--prefix "$PREFIX" ${q(`${PACKAGE_NAME}@${version}`)} --prefer-online --no-audit --no-fund --no-save --package-lock=false --fetch-retries=3 --fetch-retry-mintimeout=1000 --fetch-retry-maxtimeout=10000`;
521
+ // The restart phase, written to $LOCK/restart.sh and SUBMITTED to launchd as an
522
+ // independent one-shot job. It must not run from this detached shell: the shell
523
+ // descends from the daemon's launchd job, and booting out an ancestor job orphans
524
+ // the shell's Mach bootstrap session — every subsequent `launchctl bootstrap`
525
+ // (and `load`) then fails with EIO, leaving BOTH services unloaded until a human
526
+ // intervenes (observed in the field on the 0.1.68 rollout). A submitted job is
527
+ // parented to the gui domain itself, so it can bootout/bootstrap anything.
528
+ // After a successful submit the main script sets HANDOFF=1: lock, backup, and
529
+ // commit/rollback responsibility all transfer to this job.
530
+ const restartScript = [
531
+ `# Relay update restart phase — runs as its own launchd job (see updater log).`,
532
+ `RESTART_LABEL="$1"`,
533
+ `LOCK=${LOCK}`,
534
+ `printf '%s\n' "$$" > "$LOCK/pid" 2>/dev/null || true`,
535
+ `LIVE_TREE=${q(liveTreePath)}`,
536
+ `BACKUP=${q(backupPath)}`,
537
+ `LAUNCHCTL=${q(launchctlPath)}`,
538
+ `PILL_PLIST=${q(pillPlistPath)}`,
539
+ `DAEMON_PLIST=${q(daemonPlistPath)}`,
540
+ `log_line() { echo "[relay-update] $(date) $1" >> ${LOG} 2>&1; }`,
541
+ `restore_tree() { if [ -d "$BACKUP" ]; then rm -rf "$LIVE_TREE"; mv "$BACKUP" "$LIVE_TREE"; log_line "restored previous package tree"; fi; }`,
542
+ `start_job() { "$LAUNCHCTL" bootstrap gui/${uid} "$1" >> ${LOG} 2>&1 || "$LAUNCHCTL" load "$1" >> ${LOG} 2>&1 || "$LAUNCHCTL" kickstart -k "gui/${uid}/$2" >> ${LOG} 2>&1; }`,
543
+ // Verification with one retry: a service missing from launchd after the restart
544
+ // is bootstrapped again, loudly. Only a job listed by `launchctl print` counts.
545
+ `ensure_job() { if "$LAUNCHCTL" print "gui/${uid}/$2" >/dev/null 2>&1; then return 0; fi; log_line "$2 missing after restart; bootstrapping again"; start_job "$1" "$2"; "$LAUNCHCTL" print "gui/${uid}/$2" >/dev/null 2>&1; }`,
546
+ `RESTART_FAILED=0`,
547
+ // Pill first, daemon last (unchanged ordering contract).
548
+ `"$LAUNCHCTL" bootout gui/${uid}/${PILL_LABEL} >> ${LOG} 2>&1 || true`,
549
+ `start_job "$PILL_PLIST" ${PILL_LABEL} || true`,
550
+ `"$LAUNCHCTL" bootout gui/${uid}/${DAEMON_LABEL} >> ${LOG} 2>&1 || true`,
551
+ `start_job "$DAEMON_PLIST" ${DAEMON_LABEL} || true`,
552
+ `ensure_job "$PILL_PLIST" ${PILL_LABEL} || { log_line "pill restart verification FAILED"; RESTART_FAILED=1; }`,
553
+ `ensure_job "$DAEMON_PLIST" ${DAEMON_LABEL} || { log_line "daemon restart verification FAILED"; RESTART_FAILED=1; }`,
554
+ `if [ "$RESTART_FAILED" = 1 ]; then`,
555
+ ` log_line "restart verification FAILED; restoring previous tree"`,
556
+ ` restore_tree`,
557
+ ` "$LAUNCHCTL" bootout gui/${uid}/${PILL_LABEL} >> ${LOG} 2>&1 || true`,
558
+ ` "$LAUNCHCTL" bootout gui/${uid}/${DAEMON_LABEL} >> ${LOG} 2>&1 || true`,
559
+ ` start_job "$PILL_PLIST" ${PILL_LABEL} || true`,
560
+ ` start_job "$DAEMON_PLIST" ${DAEMON_LABEL} || true`,
561
+ ` ensure_job "$PILL_PLIST" ${PILL_LABEL} || log_line "pill STILL missing after rollback; run 'relay setup' to repair"`,
562
+ ` ensure_job "$DAEMON_PLIST" ${DAEMON_LABEL} || log_line "daemon STILL missing after rollback; run 'relay setup' to repair"`,
563
+ `else`,
564
+ ` rm -rf "$BACKUP" 2>/dev/null`,
565
+ ` log_line "update committed; pill and daemon restarted from the new tree"`,
566
+ `fi`,
567
+ `rm -rf "$LOCK" 2>/dev/null`,
568
+ // One-shot label cleanup: the submitted job removes itself from launchd. This is
569
+ // deliberately the last line — remove may terminate the job's own process.
570
+ `"$LAUNCHCTL" remove "$RESTART_LABEL" >/dev/null 2>&1 || true`,
571
+ `exit 0`,
572
+ ].join("\n");
235
573
  const script = [
236
574
  `export PATH=${pathPrefix}"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"`,
237
575
  `PREFIX=${q(install.prefix)}`,
@@ -245,8 +583,8 @@ export function spawnDetachedUpdate({
245
583
  : `LIVE_TREE="$PREFIX/node_modules"; BACKUP="$PREFIX/.relay-companion-node_modules-update-backup"`,
246
584
  `NODE=${q(execPath)}`,
247
585
  `LAUNCHCTL=${q(launchctlPath)}`,
248
- `PILL_PLIST=${q(path.join(launchAgentsDir, `${PILL_LABEL}.plist`))}`,
249
- `DAEMON_PLIST=${q(path.join(launchAgentsDir, `${DAEMON_LABEL}.plist`))}`,
586
+ `PILL_PLIST=${q(pillPlistPath)}`,
587
+ `DAEMON_PLIST=${q(daemonPlistPath)}`,
250
588
  `if [ ! -x "$NODE" ]; then NODE="$(command -v node 2>/dev/null)"; fi`,
251
589
  // Hold the lock path in a shell var and reference "$LOCK" everywhere so every use
252
590
  // (including the single-quoted trap action) stays correct even if the home path
@@ -264,8 +602,11 @@ export function spawnDetachedUpdate({
264
602
  // restore the known-running tree without relying on npm or the network. The EXIT
265
603
  // trap also covers shell termination between moving the old tree and verification.
266
604
  `KEEP_NEW=0`,
605
+ // After a successful hand-off to the independent restart job, the lock (and the
606
+ // restart.sh inside it) belong to THAT job — the exit trap must not remove them.
607
+ `HANDOFF=0`,
267
608
  `restore_tree() { if [ -d "$BACKUP" ]; then rm -rf "$LIVE_TREE"; mv "$BACKUP" "$LIVE_TREE"; echo "[relay-update] $(date) restored previous package tree" >> ${LOG} 2>&1; fi; KEEP_NEW=1; }`,
268
- `cleanup_update() { CODE=$?; trap - EXIT INT TERM; if [ "$KEEP_NEW" != 1 ]; then restore_tree; fi; rm -rf "$LOCK" 2>/dev/null; exit "$CODE"; }`,
609
+ `cleanup_update() { CODE=$?; trap - EXIT INT TERM; if [ "$KEEP_NEW" != 1 ]; then restore_tree; fi; if [ "$HANDOFF" != 1 ]; then rm -rf "$LOCK" 2>/dev/null; fi; exit "$CODE"; }`,
269
610
  `trap cleanup_update EXIT INT TERM`,
270
611
  // A SIGKILL bypasses EXIT cleanup and can leave both the known-good backup and a
271
612
  // partially written live tree. The backup always wins; never delete our only
@@ -296,16 +637,31 @@ export function spawnDetachedUpdate({
296
637
  ` if "$NODE" "$PKG/bin/relay.js" repair-desktop --no-restart${repairModeArgs ? ` ${repairModeArgs}` : ""} >> ${LOG} 2>&1; then DESKTOP_OK=1; else echo "[relay-update] $(date) desktop repair FAILED" >> ${LOG} 2>&1; fi`,
297
638
  ` fi`,
298
639
  ` if [ "$DESKTOP_OK" = 1 ]; then`,
299
- ` echo "[relay-update] $(date) install and desktop repair verified; restarting services" >> ${LOG} 2>&1`,
300
- // A kickstart does NOT reload changed ProgramArguments from disk. Bootout +
301
- // bootstrap makes version-manager/prefix repairs take effect and also revives a
302
- // job a user/agent previously unloaded. The detached shell survives booting out
303
- // its daemon parent. Pill first; daemon last.
640
+ ` echo "[relay-update] $(date) install and desktop repair verified; handing restart to an independent launchd job" >> ${LOG} 2>&1`,
641
+ // A kickstart does NOT reload changed ProgramArguments from disk, so the restart
642
+ // must bootout + bootstrap. But this shell DESCENDS from the daemon's launchd
643
+ // job: booting out that ancestor orphans this shell's Mach bootstrap session,
644
+ // after which bootstrap/load fail with EIO and both services are stranded
645
+ // unloaded (field-observed on 0.1.68). Hand the whole restart phase to a
646
+ // launchd-submitted one-shot job instead; start_job stays defined here only for
647
+ // the degraded inline fallback when submit itself is unavailable.
304
648
  ` start_job() { "$LAUNCHCTL" bootstrap gui/${uid} "$1" >> ${LOG} 2>&1 || "$LAUNCHCTL" load "$1" >> ${LOG} 2>&1 || "$LAUNCHCTL" kickstart -k "gui/${uid}/$2" >> ${LOG} 2>&1; }`,
305
- ` "$LAUNCHCTL" bootout gui/${uid}/${PILL_LABEL} >> ${LOG} 2>&1 || true`,
306
- ` if ! start_job "$PILL_PLIST" ${PILL_LABEL}; then echo "[relay-update] $(date) pill restart FAILED; restoring previous tree" >> ${LOG} 2>&1; restore_tree; start_job "$PILL_PLIST" ${PILL_LABEL} || true; exit 1; fi`,
307
- ` "$LAUNCHCTL" bootout gui/${uid}/${DAEMON_LABEL} >> ${LOG} 2>&1 || true`,
308
- ` if start_job "$DAEMON_PLIST" ${DAEMON_LABEL}; then KEEP_NEW=1; rm -rf "$BACKUP" 2>/dev/null; else echo "[relay-update] $(date) daemon restart FAILED; restoring previous tree" >> ${LOG} 2>&1; restore_tree; "$LAUNCHCTL" bootout gui/${uid}/${PILL_LABEL} >> ${LOG} 2>&1 || true; start_job "$PILL_PLIST" ${PILL_LABEL} || true; start_job "$DAEMON_PLIST" ${DAEMON_LABEL} || true; exit 1; fi`,
649
+ ` cat > "$LOCK/restart.sh" <<'RELAY_RESTART_EOF'`,
650
+ restartScript,
651
+ `RELAY_RESTART_EOF`,
652
+ ` RESTART_LABEL="work.relay.update.restart.$(date +%s).$$"`,
653
+ ` if "$LAUNCHCTL" submit -l "$RESTART_LABEL" -- /bin/sh "$LOCK/restart.sh" "$RESTART_LABEL" >> ${LOG} 2>&1; then`,
654
+ ` KEEP_NEW=1; HANDOFF=1`,
655
+ ` echo "[relay-update] $(date) restart phase submitted as $RESTART_LABEL (lock, backup, and rollback now owned by that job)" >> ${LOG} 2>&1`,
656
+ ` else`,
657
+ ` echo "[relay-update] $(date) launchctl submit FAILED; degraded inline restart (pill reload; daemon kickstart without plist reload)" >> ${LOG} 2>&1`,
658
+ ` "$LAUNCHCTL" bootout gui/${uid}/${PILL_LABEL} >> ${LOG} 2>&1 || true`,
659
+ ` if ! start_job "$PILL_PLIST" ${PILL_LABEL}; then echo "[relay-update] $(date) pill restart FAILED; restoring previous tree" >> ${LOG} 2>&1; restore_tree; start_job "$PILL_PLIST" ${PILL_LABEL} || true; exit 1; fi`,
660
+ // Never bootout our own ancestor inline (the EIO trap above). kickstart -k
661
+ // restarts the daemon without removing its job; stale ProgramArguments, if any,
662
+ // are corrected by the next successful submit-based update.
663
+ ` if "$LAUNCHCTL" kickstart -k "gui/${uid}/${DAEMON_LABEL}" >> ${LOG} 2>&1; then KEEP_NEW=1; rm -rf "$BACKUP" 2>/dev/null; else echo "[relay-update] $(date) daemon kickstart FAILED; restoring previous tree" >> ${LOG} 2>&1; restore_tree; "$LAUNCHCTL" bootout gui/${uid}/${PILL_LABEL} >> ${LOG} 2>&1 || true; start_job "$PILL_PLIST" ${PILL_LABEL} || true; "$LAUNCHCTL" kickstart -k "gui/${uid}/${DAEMON_LABEL}" >> ${LOG} 2>&1 || true; exit 1; fi`,
664
+ ` fi`,
309
665
  ` else`,
310
666
  ` echo "[relay-update] $(date) post-install verify FAILED; restoring ${restore} from local backup" >> ${LOG} 2>&1`,
311
667
  ` exit 1`,
@@ -316,14 +672,7 @@ export function spawnDetachedUpdate({
316
672
  `fi`,
317
673
  ].join("\n");
318
674
  const child = spawnImpl("/bin/sh", ["-c", script], { detached: true, stdio: "ignore" });
319
- // A detached child with NO 'error' listener turns an async spawn failure (EMFILE,
320
- // EAGAIN, ENOENT on /bin/sh) into an unhandled 'error' that would crash this
321
- // always-on daemon. Swallow it — the update simply doesn't happen this cycle.
322
- if (child && typeof child.on === "function") {
323
- child.on("error", (err) => log(`auto-update spawn error: ${err && err.message ? err.message : String(err)}`));
324
- }
325
- if (child && typeof child.unref === "function") child.unref();
326
- return child;
675
+ return attachAndRelease(child);
327
676
  }
328
677
 
329
678
  // ---- orchestrator -------------------------------------------------------
@@ -390,7 +739,7 @@ export function createAutoUpdater({
390
739
  state.updating = true;
391
740
  state.updateStartedAt = t;
392
741
  try {
393
- spawnUpdate({ log, currentVersion: runningVersion, targetVersion: latest, packageRoot, mode: normalizedMode });
742
+ spawnUpdate({ log, currentVersion: runningVersion, targetVersion: latest, packageRoot, mode: normalizedMode, platform });
394
743
  } catch (err) {
395
744
  state.updating = false;
396
745
  log(`auto-update launch failed: ${err && err.message ? err.message : String(err)}`);
@@ -401,15 +750,16 @@ export function createAutoUpdater({
401
750
 
402
751
  async function tick() {
403
752
  if (!autoUpdateEnabled(env)) return { status: "disabled" };
404
- // Only darwin has the launchctl restart path today. On win32/linux the detached
405
- // /bin/sh + launchctl script is a no-op that would falsely report "updating" and
406
- // then never restart — so the daemon would run stale code forever while thinking
407
- // it self-updates. Gate explicitly until a real per-platform path exists.
408
- if (platform !== "darwin") return { status: "unsupported-platform" };
753
+ // Only platforms with a real restart path may update: darwin (launchd bootout +
754
+ // bootstrap, handed to an independent submitted job) and win32 (Scheduled Tasks
755
+ // via schtasks /End + /Run, pill first, daemon last). Elsewhere a detached
756
+ // script would falsely report "updating" and never restart — the daemon would
757
+ // run stale code forever while thinking it self-updates. Gate explicitly.
758
+ if (platform !== "darwin" && platform !== "win32") return { status: "unsupported-platform" };
409
759
  // The broad layout check is useful for diagnostics, but only update trees we can
410
760
  // safely roll back as one unit: a true global install or Relay's dedicated local
411
761
  // fallback prefix. Never adopt an arbitrary project's hoisted node_modules.
412
- if (!managedInstallInfo(packageRoot)) return { status: "unmanaged" };
762
+ if (!managedInstallInfo(packageRoot, { platform })) return { status: "unmanaged" };
413
763
  const t = now();
414
764
  // An update was launched but we're still alive (install failed, or restart is
415
765
  // pending) — hold off briefly, then retry the exact pending version. A successful
@@ -489,7 +839,7 @@ export async function runUpdateOnce({
489
839
  log("could not reach the npm registry to check for updates.");
490
840
  break;
491
841
  case "unsupported-platform":
492
- log("self-update is only supported on macOS right now — update with `npm i -g relay-companion@latest`.");
842
+ log("self-update is only supported on macOS and Windows right now — update with `npm i -g relay-companion@latest`.");
493
843
  break;
494
844
  case "deferred-busy":
495
845
  log(`an update to ${result.latest} is ready but deferred while an agent turn is active; it will install once idle.`);