local-mcp 3.0.378 → 3.0.380

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/download.js +102 -35
  2. package/package.json +1 -1
package/download.js CHANGED
@@ -52,6 +52,10 @@ const TRAY_APP = process.platform === 'darwin'
52
52
  ? path.join(os.homedir(), 'Applications', 'LocalMCPTray.app')
53
53
  : null
54
54
 
55
+ // La instalación vieja que TRAY_APP supersede. Const (no literal suelto) porque el orden en
56
+ // que se la retira es lo que arregla #2654 y los tests la inyectan.
57
+ const SYSTEM_TRAY_APP = '/Applications/LocalMCPTray.app'
58
+
55
59
  // Mirror of go-server's CacheDir()/.machine-id (internal/machineid/machineid.go).
56
60
  // Only the win32 ladder reads/writes it: it's the side of the probe that can
57
61
  // come up empty on a real machine (wmic gone, PowerShell blocked by policy),
@@ -532,12 +536,65 @@ async function ensureTeamsProxy(platform = process.platform) {
532
536
  * Descarga e instala el tray SwiftUI (menu bar app) — universal binary (arm64 + Intel).
533
537
  * @returns {Promise<string|null>} Ruta al .app instalado, o null si falla
534
538
  */
535
- async function ensureTray() {
539
+ // Un .app "usable" es el EJECUTABLE adentro, no el directorio: una extracción a medias deja
540
+ // el bundle existiendo y vacío, y esa fue la falla de #1338. (#2654)
541
+ function _isUsableTrayBundle(trayApp) {
542
+ try { return fs.existsSync(path.join(trayApp, 'Contents', 'MacOS', 'LocalMCPTray')) }
543
+ catch { return false }
544
+ }
545
+
546
+ // Retira las copias que `trayApp` supersede — SOLO si `trayApp` ya tiene un bundle usable.
547
+ // Sin esa guarda, la migración borra la única copia que el Mac tiene antes de que exista su
548
+ // reemplazo, y una descarga fallida deja la máquina sin cliente (#2654, hermano de #2643).
549
+ // Devuelve lo retirado para que los tests puedan afirmar sobre el efecto, no sobre el orden.
550
+ function _retireSupersededTrays(trayApp, supersededPaths, rm = _rmrf) {
551
+ if (!trayApp || !_isUsableTrayBundle(trayApp)) return []
552
+ const retired = []
553
+ for (const p of supersededPaths) {
554
+ if (!p) continue
555
+ if (_sameLocation(p, trayApp)) continue
556
+ if (!fs.existsSync(p)) continue
557
+ try { rm(p); retired.push(p) } catch { /* ignorar */ }
558
+ }
559
+ return retired
560
+ }
561
+
562
+ // Identidad, no grafía: con ~/Applications symlinkeado a /Applications los dos nombres son el
563
+ // MISMO directorio, y un path.resolve() los ve distintos — el retiro se llevaba puesto el bundle
564
+ // recién instalado y la máquina no volvía a tener cliente nunca. Cubre symlinks, no diferencias
565
+ // de mayúsculas: realpathSync no normaliza case en APFS, y ningún alias así es alcanzable desde
566
+ // TRAY_APP/SYSTEM_TRAY_APP, que son constantes. (#2654)
567
+ function _sameLocation(a, b) {
568
+ try { return fs.realpathSync(a) === fs.realpathSync(b) }
569
+ catch { return path.resolve(a) === path.resolve(b) }
570
+ }
571
+
572
+ function _rmrf(p) {
573
+ execFileSync('rm', ['-rf', p], { stdio: 'pipe' })
574
+ }
575
+
576
+ // `deps` es la costura que hace testeable el ORDEN de esta función sin red ni HOME real:
577
+ // los tests de #2654 la corren entera con dirs temporales y una descarga que falla. Mismo
578
+ // idioma que _getMachineId(deps = {}). Producción no pasa nada y usa los valores de módulo.
579
+ async function ensureTray(deps = {}) {
580
+ const {
581
+ platform = process.platform,
582
+ trayApp = TRAY_APP,
583
+ trayDir = TRAY_DIR,
584
+ systemTrayApp = SYSTEM_TRAY_APP,
585
+ uninstalledMarkers = null,
586
+ getLatest = getLatestBinary,
587
+ download = downloadFile,
588
+ extract = extractTar,
589
+ writeLaunchAgent = _writeTrayLaunchAgent,
590
+ isTTY = () => process.stdin.isTTY,
591
+ } = deps
592
+
536
593
  // Windows/Linux: the tray + background daemon are installed by the native
537
594
  // installer (LMCP-Setup.exe on Windows; the systemd unit on Linux), NOT by npm.
538
595
  // The npm package is a stdio proxy for MCP hosts, so there is no tray to fetch
539
596
  // here — no-op instead of downloading a lmcp-tray binary.
540
- if (process.platform !== 'darwin') {
597
+ if (platform !== 'darwin') {
541
598
  return null
542
599
  }
543
600
 
@@ -545,47 +602,51 @@ async function ensureTray() {
545
602
  // Checks BOTH the runtime-dir copy and the durable App Support copy: a user who
546
603
  // "cleaned up" with `rm -rf ~/.local/share/local-mcp` removes the runtime-dir copy,
547
604
  // and only the durable one survives to stop a resurrection loop (FB-D1FA79).
548
- const uninstalledMarkers = [
605
+ const markers = uninstalledMarkers || [
549
606
  path.join(CACHE_DIR, '..', '.uninstalled'),
550
607
  path.join(os.homedir(), 'Library', 'Application Support', 'Local MCP', '.uninstalled'),
551
608
  ]
552
- if (uninstalledMarkers.some((p) => { try { return fs.existsSync(p) } catch { return false } })) return null
609
+ if (markers.some((p) => { try { return fs.existsSync(p) } catch { return false } })) return null
553
610
 
554
- const info = await getLatestBinary()
611
+ const info = await getLatest()
555
612
  const version = info.version
556
- const trayApp = TRAY_APP
557
- const verFile = path.join(TRAY_DIR, '.version')
558
-
559
- // Migrar instalación vieja de ~/.local/share si existe
560
- const oldTrayApp = path.join(TRAY_DIR, 'LocalMCPTray.app')
561
- if (fs.existsSync(oldTrayApp)) {
562
- try { execFileSync('rm', ['-rf', oldTrayApp], { stdio: 'pipe' }) } catch { /* ignorar */ }
563
- }
564
-
565
- // Migrar instalación vieja de /Applications → ~/Applications (evita diálogo App Management)
566
- const systemTrayApp = '/Applications/LocalMCPTray.app'
567
- if (fs.existsSync(systemTrayApp) && trayApp !== systemTrayApp) {
568
- try { execFileSync('rm', ['-rf', systemTrayApp], { stdio: 'pipe' }) } catch { /* ignorar */ }
569
- }
570
-
571
- // Ya instalado y actualizado
572
- if (fs.existsSync(trayApp) && fs.existsSync(verFile)) {
573
- if (fs.readFileSync(verFile, 'utf8').trim() === version) return trayApp
613
+ const verFile = path.join(trayDir, '.version')
614
+
615
+ // Las dos migraciones —~/.local/share (legacy) y /Applications → ~/Applications (evita el
616
+ // diálogo App Management)— retiran una copia que puede ser la ÚNICA del Mac. Por eso el
617
+ // retiro corre en exactamente dos lugares: pegado a cada early-return (no se instala nada,
618
+ // el tray de destino ya está verificado) y después de extraer y verificar. Nunca en el
619
+ // camino que descarga: ahí una falla dejaba la máquina sin cliente. (#2654)
620
+ const superseded = [path.join(trayDir, 'LocalMCPTray.app'), systemTrayApp]
621
+ const retireSuperseded = () => _retireSupersededTrays(trayApp, superseded)
622
+
623
+ // Ya instalado y actualizado. "Instalado" es el bundle USABLE, no el directorio: si no,
624
+ // una extracción a medias se early-returnea para siempre y nadie la repara (#1338).
625
+ if (_isUsableTrayBundle(trayApp) && fs.existsSync(verFile)) {
626
+ if (fs.readFileSync(verFile, 'utf8').trim() === version) {
627
+ retireSuperseded()
628
+ return trayApp
629
+ }
574
630
  }
575
631
 
576
632
  // In an active MCP session (non-TTY) the tray is running and self-updates via UpdateManager.
577
633
  // Downloading a new tray bundle and rm-rf'ing the old one mid-session kills the Unix socket
578
634
  // that local-mcp-server uses, crashing the session. Skip the update; it'll apply next login.
579
- if (!process.stdin.isTTY && fs.existsSync(trayApp)) return trayApp
635
+ // Un bundle sin ejecutable no tiene tray corriendo que esta descarga pueda romper, así que
636
+ // ese caso NO se early-returnea: se repara.
637
+ if (!isTTY() && _isUsableTrayBundle(trayApp)) {
638
+ retireSuperseded()
639
+ return trayApp
640
+ }
580
641
 
581
642
  const url = `https://download.local-mcp.com/local-mcp-tray-${version}-darwin-universal.tar.gz`
582
643
  process.stderr.write(`\nDescargando tray v${version}...\n`)
583
644
 
584
- fs.mkdirSync(TRAY_DIR, { recursive: true })
585
- const tarPath = path.join(TRAY_DIR, `tray-${version}.tar.gz`)
645
+ fs.mkdirSync(trayDir, { recursive: true })
646
+ const tarPath = path.join(trayDir, `tray-${version}.tar.gz`)
586
647
 
587
648
  // Descargar ANTES de borrar el viejo — si la descarga falla, el tray actual sigue intacto
588
- await downloadFile(url, tarPath)
649
+ await download(url, tarPath)
589
650
 
590
651
  // Verificar que el archivo descargado no está vacío
591
652
  const tarStat = fs.statSync(tarPath)
@@ -596,33 +657,38 @@ async function ensureTray() {
596
657
 
597
658
  // Recién ahora borrar el viejo e instalar el nuevo
598
659
  if (fs.existsSync(trayApp)) {
599
- try { execFileSync('rm', ['-rf', trayApp], { stdio: 'pipe' }) } catch { /* ignorar */ }
660
+ try { _rmrf(trayApp) } catch { /* ignorar */ }
600
661
  }
601
662
 
602
663
  // Crear ~/Applications si no existe (ubicación estándar para apps de usuario)
603
- const userAppsDir = path.join(os.homedir(), 'Applications')
664
+ const userAppsDir = path.dirname(trayApp)
604
665
  fs.mkdirSync(userAppsDir, { recursive: true })
605
666
 
606
667
  process.stderr.write(` Instalando tray en ~/Applications...\n`)
607
- extractTar(tarPath, userAppsDir)
668
+ extract(tarPath, userAppsDir)
608
669
  fs.unlinkSync(tarPath)
609
670
 
610
- if (!fs.existsSync(trayApp)) {
611
- throw new Error(`Tray no encontrado tras extraer: ${trayApp}`)
671
+ // Mismo criterio que usa el retiro, o se estampa .version sobre un bundle roto y el
672
+ // early-return de arriba lo da por bueno en cada corrida siguiente.
673
+ if (!_isUsableTrayBundle(trayApp)) {
674
+ throw new Error(`Tray no usable tras extraer: ${trayApp}`)
612
675
  }
613
676
 
677
+ // El reemplazo está instalado y verificado: recién ACÁ se retira lo que supersede.
678
+ retireSuperseded()
679
+
614
680
  // Quitar quarantine — la app viene firmada con Developer ID desde CI, no re-firmar
615
681
  try { execFileSync('xattr', ['-rd', 'com.apple.quarantine', trayApp], { stdio: 'pipe' }) } catch { /* no crítico */ }
616
682
 
617
683
  fs.writeFileSync(verFile, version, 'utf8')
618
684
  // Keep in sync with UpdateManager / Heartbeat (was .version only → fleet stuck on old tray)
619
- const trayVerFile = path.join(TRAY_DIR, '.tray-version')
685
+ const trayVerFile = path.join(trayDir, '.tray-version')
620
686
  fs.writeFileSync(trayVerFile, version, 'utf8')
621
687
 
622
688
  // LaunchAgent — auto-start en cada reboot de sesión
623
- _writeTrayLaunchAgent(trayApp)
689
+ writeLaunchAgent(trayApp)
624
690
 
625
- process.stderr.write(` Tray instalado en ${trayApp}${process.stdin.isTTY ? '' : ' (se iniciará en el próximo login)'}\n`)
691
+ process.stderr.write(` Tray instalado en ${trayApp}${isTTY() ? '' : ' (se iniciará en el próximo login)'}\n`)
626
692
  return trayApp
627
693
  }
628
694
 
@@ -727,4 +793,5 @@ module.exports = {
727
793
  CACHE_DIR, TRAY_DIR, _getMachineId,
728
794
  _probeWinHardwareId, _resetWinHardwareIdCacheForTests,
729
795
  _readWinMachineIdCache, _writeWinMachineIdCache, WIN_MACHINE_ID_CACHE_NAME,
796
+ _retireSupersededTrays, _isUsableTrayBundle, SYSTEM_TRAY_APP,
730
797
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-mcp",
3
- "version": "3.0.378",
3
+ "version": "3.0.380",
4
4
  "description": "Let ChatGPT, Claude, Cursor & any MCP client actually use your Mac — read & reply to email, manage your calendar, text over iMessage, find files, work with Teams, Slack & Office. On your Mac, no API keys, free.",
5
5
  "main": "index.js",
6
6
  "bin": {