@glassnote/client 2.4.10 → 2.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/INSTALACION.md CHANGED
@@ -41,6 +41,7 @@ que es justo lo que hace `glassnote uninstall`.
41
41
  | `glassnote start --detach` | lo arranca y libera la terminal |
42
42
  | `glassnote install [version]` | instala o reinstala (`latest` por defecto) |
43
43
  | `glassnote update [version]` | actualiza a la última publicada |
44
+ | `glassnote pair` | da de alta el equipo con la API key de la organización |
44
45
  | `glassnote uninstall` | quita autoarranque, `~/.glassnote` y la línea del rc |
45
46
  | `glassnote status` | versión, modo, rutas, autoarranque y estado del sandbox |
46
47
  | `glassnote fix-sandbox` | (Linux) restaura el sandbox de Chromium; pide sudo una vez |
@@ -48,6 +49,89 @@ que es justo lo que hace `glassnote uninstall`.
48
49
  Banderas de `install`: `--no-path` (no toca el rc, imprime la línea) y `--no-start`
49
50
  (instala y no arranca).
50
51
 
52
+ ## Alta masiva: emparejar sin que nadie mire la pantalla
53
+
54
+ El emparejamiento normal es a dos manos: el cliente muestra un código y una persona lo
55
+ escribe en la web. Para un equipo está bien. Para trescientos —una cadena que abre
56
+ locales, un parque que se reinstala entero— es una tarde entera y un error de tipeo cada
57
+ veinte equipos.
58
+
59
+ Con la **API key de la organización**, el equipo se da de alta solo durante la
60
+ instalación. La API contesta con el servidor y el token, el instalador los deja escritos
61
+ donde la app los busca, y el cliente arranca ya conectado.
62
+
63
+ ```sh
64
+ # Linux / macOS — ojo con el `-s --`, es lo que hace que sh le pase las banderas al script
65
+ curl -fsSL https://glassnote.intermark.ec/install.sh | sh -s -- \
66
+ --org-id 55 --api-key <clave> --device-name "Caja 01" --user-name "Santiago"
67
+ ```
68
+
69
+ ```powershell
70
+ # Windows
71
+ & ([scriptblock]::Create((irm https://glassnote.intermark.ec/install.ps1))) `
72
+ -OrgId 55 -ApiKey <clave> -DeviceName 'Caja 01' -UserName 'Santiago'
73
+ ```
74
+
75
+ | bandera | variable de entorno | por defecto |
76
+ |---|---|---|
77
+ | `--org-id` | `GLASSNOTE_ORG_ID` | — (obligatoria) |
78
+ | `--api-key` | `GLASSNOTE_API_KEY` | — (obligatoria) |
79
+ | `--device-name` | `GLASSNOTE_DEVICE_NAME` | el hostname del equipo |
80
+ | `--user-name` | `GLASSNOTE_USER_NAME` | el usuario del sistema |
81
+ | `--api-url` | `GLASSNOTE_API_URL` | `https://glassnoteapi.intermark.ec` |
82
+ | `--ws-url` | `GLASSNOTE_WS_URL` | el que anuncie la API |
83
+
84
+ Las variables de entorno existen porque un despliegue por GPO o por MDM muchas veces no
85
+ puede pasar argumentos pero sí poner entorno.
86
+
87
+ ### Otro servidor
88
+
89
+ **El websocket no se elige en el instalador: lo decide la API que atiende el alta.** El
90
+ token que emite sólo vale en su propio websocket, así que apuntar a otro sitio no daría
91
+ un equipo conectado a otro servidor, daría un equipo dado de alta que no conecta. Para
92
+ instalar contra otra API se cambia `--api-url` y el websocket viene de vuelta en la
93
+ respuesta:
94
+
95
+ ```sh
96
+ curl -fsSL https://glassnote.intermark.ec/install.sh | sh -s -- \
97
+ --api-url https://api.otrocliente.com --org-id 12 --api-key <clave>
98
+ ```
99
+
100
+ Esa URL de websocket es además de donde el cliente saca el origen HTTP de la API para
101
+ reportar su versión, así que es **el único dato de servidor** que el equipo guarda.
102
+
103
+ `--ws-url` cubre el único caso que lo anterior no: que la API anuncie una dirección a la
104
+ que el equipo no llega —una IP de LAN detrás de un NAT, un proxy inverso publicado con
105
+ otro nombre, DNS partido—. Ahí el alta sale bien y los equipos guardan una URL muerta sin
106
+ ninguna señal en el momento de instalar. Por eso, cuando el host que anuncia la API no es
107
+ el mismo al que se pidió el alta, el instalador lo dice en pantalla. **Lo correcto es
108
+ arreglar `WEBSOCKET_URL` en la API**; `--ws-url` es la salida cuando no se puede.
109
+
110
+ ```sh
111
+ glassnote pair --org-id 12 --api-key <clave> \
112
+ --api-url https://api.cliente.local --ws-url wss://glassnote.cliente.com/ws
113
+ ```
114
+
115
+ **El nombre por defecto es el del propio equipo, no uno genérico.** Es lo que hace que el
116
+ *mismo* comando sirva para todo el parque: sin eso, el tablero termina con trescientos
117
+ "Unnamed Device" y hay que renombrarlos a mano, que es justo el trabajo que esto viene a
118
+ evitar.
119
+
120
+ La API key se saca en la web, en las claves de la organización. **Da de alta equipos en
121
+ esa organización**: es un secreto de despliegue, no algo que se reparta. Caduca a los 30
122
+ días, lo que acota lo que se puede hacer con una que se filtre.
123
+
124
+ Un equipo ya instalado se empareja sin reinstalar:
125
+
126
+ ```sh
127
+ glassnote pair --org-id 55 --api-key <clave> --device-name "Caja 01"
128
+ ```
129
+
130
+ Si el alta falla, el comando **sale con código distinto de cero** y dice por qué (la
131
+ clave no sirve, la organización no es la de esa clave, el plan llegó a su tope de
132
+ equipos). El cliente queda instalado igual: se reintenta con `glassnote pair`. Emparejar
133
+ dos veces el mismo equipo no lo duplica ni gasta un cupo extra; solo le renueva el token.
134
+
51
135
  `install` y `update` también aceptan un *spec* de npm en vez de una versión: un `.tgz`,
52
136
  una ruta local o una URL. Es lo que permite probar un paquete antes de publicarlo:
53
137
 
package/bin/glassnote.js CHANGED
@@ -17,6 +17,7 @@ const os = require('os');
17
17
  const { spawn, spawnSync } = require('child_process');
18
18
 
19
19
  const mode = require('../npmMode');
20
+ const pairing = require('../pairing');
20
21
  const pkg = require('../package.json');
21
22
 
22
23
  const IS_WIN = process.platform === 'win32';
@@ -140,6 +141,78 @@ function esperarAQueCierre(maxMs) {
140
141
  return false;
141
142
  }
142
143
 
144
+ // Windows no deja reemplazar los archivos de un proceso vivo. Reinstalar encima del
145
+ // cliente abierto muere con «EBUSY: resource busy or locked, rename ...\electron\dist\
146
+ // icudtl.dat», y muere A MITAD: npm ya apartó parte del árbol viejo, así que la
147
+ // instalación que había queda rota además de no haber instalado la nueva. Es el fallo
148
+ // que se lleva puesto a cualquiera que corra el `irm | iex` una segunda vez, que es
149
+ // justo lo que se le pide a la gente para actualizar.
150
+ //
151
+ // Se cierra por RUTA del ejecutable, nunca por nombre: "electron.exe" es también el
152
+ // VS Code y el Slack de quien esté delante, y cerrarle eso para instalar esto sería
153
+ // bastante peor que el fallo que se viene a arreglar. Sólo se toca lo que vive dentro
154
+ // de ~/.glassnote, que es nuestro por definición.
155
+ // El guion de PowerShell, aparte: decidir QUE se le pide al sistema es una funcion pura
156
+ // y se puede comprobar desde cualquier maquina; matar procesos de verdad, no. Y la parte
157
+ // que mas silenciosamente se rompe es justo esta, el entrecomillado de la ruta.
158
+ //
159
+ // Comillas SIMPLES de PowerShell: son literales y no interpretan la barra invertida. Con
160
+ // comillas dobles habria que escapar, y una ruta escapada a la manera de JSON le llega a
161
+ // PowerShell con las barras duplicadas y no coincide con nada: el filtro no encontraria
162
+ // un solo proceso y el EBUSY volveria, mudo.
163
+ //
164
+ // El filtro apunta a `<home>\npm\node_modules`, NO a `<home>` entero. La diferencia no es
165
+ // cosmetica: cuando el equipo no tenia Node, el instalador se baja el suyo a
166
+ // `<home>\node-v20.18.1-win-x64\node.exe` —que es el caso normal en Windows— y ese
167
+ // node.exe es el que esta ejecutando ESTE codigo. Filtrando por `<home>` el instalador se
168
+ // mataba a si mismo a mitad, salia con codigo -1 y no instalaba nada. Se vio en la VM de
169
+ // Windows el 2026-09-07; en Linux no pasa porque alli no hay nada que cerrar.
170
+ //
171
+ // Se excluye ademas el pid propio, por si algun dia el CLI viviera dentro de esa carpeta.
172
+ // Donde viven los binarios del cliente YA INSTALADO. Es lo unico que npm va a
173
+ // reemplazar, y por tanto lo unico que hay que cerrar.
174
+ function rutaDelClienteInstalado() {
175
+ return path.join(mode.NPM_PREFIX, 'node_modules');
176
+ }
177
+
178
+ function guionCerrarCliente(raizDir, pidPropio) {
179
+ const raiz = `'${String(raizDir).replace(/'/g, "''")}'`;
180
+ const pid = Number(pidPropio) || 0;
181
+ return [
182
+ `$raiz = ${raiz}`,
183
+ `$yo = ${pid}`,
184
+ '$procs = @(Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $yo -and $_.ExecutablePath -and $_.ExecutablePath.StartsWith($raiz, [System.StringComparison]::OrdinalIgnoreCase) })',
185
+ '$procs | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }',
186
+ 'Write-Output $procs.Count',
187
+ ].join('; ');
188
+ }
189
+
190
+ function cerrarClienteWindows() {
191
+ if (!IS_WIN) return true; // en POSIX se puede reemplazar un archivo abierto
192
+
193
+ const res = spawnSync(
194
+ 'powershell.exe',
195
+ ['-NoProfile', '-NonInteractive', '-Command', guionCerrarCliente(rutaDelClienteInstalado(), process.pid)],
196
+ { encoding: 'utf8', timeout: 20000 }
197
+ );
198
+
199
+ // Si PowerShell no está o falló, no se sigue a ciegas: se mira si el cliente
200
+ // contesta, que es la señal barata de que sigue vivo.
201
+ if (res.error || res.status !== 0) {
202
+ if (!clienteEnMarcha()) return true;
203
+ console.error('glassnote: no pude cerrar el cliente automáticamente.');
204
+ return false;
205
+ }
206
+
207
+ const cerrados = parseInt(String(res.stdout || '').trim(), 10) || 0;
208
+ if (cerrados === 0) return true;
209
+
210
+ step(`el cliente estaba abierto: cerrados ${cerrados} procesos suyos`);
211
+ // El proceso desaparece de la lista antes de que el SO suelte los handles de sus
212
+ // archivos, y npm necesita los handles, no la lista.
213
+ return esperarAQueCierre(15000);
214
+ }
215
+
143
216
  const log = (...args) => console.log(...args);
144
217
  // Cada paso se dice EN CUANTO EMPIEZA: lo que hace que un instalador parezca colgado
145
218
  // no es que tarde, es que tarde sin decir en qué está.
@@ -464,16 +537,29 @@ function borrarAppMac() {
464
537
  }
465
538
  }
466
539
 
467
- function cmdInstall(args) {
468
- const version = args.find((a) => !a.startsWith('-')) || 'latest';
540
+ async function cmdInstall(args) {
541
+ // El argumento suelto es la version, pero las banderas de emparejamiento traen valor
542
+ // propio: sin saltarlo, `install --api-key abc` intentaba instalar la version "abc".
543
+ const version = pairing.argumentoPosicional(args) || 'latest';
469
544
  const noPath = args.includes('--no-path');
470
545
  const noStart = args.includes('--no-start');
546
+ const emparejamiento = pairing.leerOpciones(args, process.env);
471
547
 
472
548
  const spec = toSpec(version);
473
549
  log(`glassnote — instalando ${spec}`);
474
550
  fs.mkdirSync(mode.NPM_PREFIX, { recursive: true });
475
551
  fs.mkdirSync(mode.BIN_DIR, { recursive: true });
476
552
 
553
+ // ANTES de que npm mueva un solo archivo: si el cliente está abierto, npm apartaría
554
+ // el árbol viejo a medias y dejaría rota la instalación que ya funcionaba.
555
+ if (!cerrarClienteWindows()) {
556
+ fail(
557
+ 'el cliente sigue abierto y Windows no deja reemplazar sus archivos.\n' +
558
+ ' Cerralo desde el ícono de la bandeja (clic derecho → Salir) y volvé a correr esto.\n' +
559
+ ' No se tocó nada: la instalación que tenías sigue como estaba.'
560
+ );
561
+ }
562
+
477
563
  // Los scripts de instalación van ACTIVADOS a propósito: el binario de electron se
478
564
  // baja en un postinstall. Con --ignore-scripts quedaría instalado y roto.
479
565
  step(`npm install -g --prefix ${mode.NPM_PREFIX} (sin root, la primera vez tarda: baja electron)`);
@@ -530,6 +616,14 @@ function cmdInstall(args) {
530
616
  log('Para actualizar más adelante: glassnote update (o correr esto mismo otra vez).');
531
617
  log('');
532
618
 
619
+ // Antes de arrancar: si la instalacion trae credenciales de organizacion, el equipo se
620
+ // da de alta ahora. Arrancarlo primero solo consigue que aparezca la pantalla del
621
+ // keycode un instante antes de quedar emparejado igual.
622
+ if (pairing.pidenEmparejar(emparejamiento)) {
623
+ const codigo = await emparejar(emparejamiento, { reciénInstalado: true });
624
+ if (codigo !== 0) return codigo;
625
+ }
626
+
533
627
  if (noStart) {
534
628
  log('Arrancalo cuando quieras: glassnote');
535
629
  return 0;
@@ -549,6 +643,66 @@ function cmdInstall(args) {
549
643
  return 0;
550
644
  }
551
645
 
646
+ // Alta desatendida contra la API. Devuelve codigo de salida y no lanza: un despliegue
647
+ // masivo se guia por el codigo, no por lo que aparezca en pantalla.
648
+ async function emparejar(opciones, { reciénInstalado = false } = {}) {
649
+ const userData = conUserData((u) => u);
650
+ if (!userData) {
651
+ console.error('glassnote: no pude leer los datos de usuario para emparejar.');
652
+ return 1;
653
+ }
654
+
655
+ step(`dando de alta el equipo en la organizacion ${opciones.orgId || '(sin id)'}`);
656
+
657
+ try {
658
+ const alta = await pairing.emparejar(opciones, userData);
659
+ log('');
660
+ log(
661
+ alta.yaEstaba
662
+ ? 'El equipo ya estaba en la organizacion; se le renovo el token.'
663
+ : 'Equipo dado de alta.'
664
+ );
665
+ log(` nombre: ${alta.name}`);
666
+ log(` organizacion: ${alta.organizationId}`);
667
+ log(` servidor: ${alta.server}${alta.servidorForzado ? ' (forzado con --ws-url)' : ''}`);
668
+ log(` uuid: ${alta.uuid}`);
669
+ log('');
670
+ // El equipo va a hablar SOLO con este websocket, y de él saca también el origen
671
+ // HTTP de la API. Si no es el mismo host al que se acaba de pedir el alta, la API
672
+ // está anunciando otra dirección: puede ser a propósito, o puede ser un
673
+ // WEBSOCKET_URL mal puesto que deja al parque entero conectando a la nada. Se
674
+ // dice acá, que es el único momento en que alguien está mirando.
675
+ if (!alta.mismoOrigenQueLaApi) {
676
+ log(`Ojo: la API contestó que el equipo se conecte a ${alta.servidorAnunciado || alta.server},`);
677
+ log('que no es el host al que le pediste el alta. Si el equipo no llega ahí, se');
678
+ log('arregla WEBSOCKET_URL en la API, o se fuerza acá con --ws-url.');
679
+ log('');
680
+ }
681
+ return 0;
682
+ } catch (error) {
683
+ console.error('');
684
+ console.error(`glassnote: no se pudo dar de alta el equipo: ${error.message}`);
685
+ if (reciénInstalado) {
686
+ console.error('El cliente quedó instalado igual. Cuando esto se resuelva:');
687
+ } else {
688
+ console.error('El equipo sigue como estaba. Para reintentar:');
689
+ }
690
+ console.error(' glassnote pair --org-id <id> --api-key <clave> --device-name <nombre>');
691
+ console.error('');
692
+ return 1;
693
+ }
694
+ }
695
+
696
+ async function cmdPair(args) {
697
+ const opciones = pairing.leerOpciones(args, process.env);
698
+ if (!pairing.pidenEmparejar(opciones)) {
699
+ console.error('glassnote pair: falta --api-key (o la variable GLASSNOTE_API_KEY).\n');
700
+ cmdHelp();
701
+ return 2;
702
+ }
703
+ return emparejar(opciones);
704
+ }
705
+
552
706
  function cmdUpdate(args) {
553
707
  if (mode.isEphemeral()) {
554
708
  log('Estás corriendo por npx: npx ya baja la última versión cada vez.');
@@ -571,6 +725,16 @@ function cmdUpdate(args) {
571
725
 
572
726
  if (waitMs > 0) esperarAQueCierre(waitMs);
573
727
 
728
+ // Sin `--wait` esto lo está corriendo una persona a mano, con el cliente abierto: el
729
+ // mismo EBUSY de Windows que en install, y con el mismo remedio.
730
+ else if (!cerrarClienteWindows()) {
731
+ fail(
732
+ 'el cliente sigue abierto y Windows no deja reemplazar sus archivos.\n' +
733
+ ' Cerralo desde el ícono de la bandeja (clic derecho → Salir) y volvé a intentar.\n' +
734
+ ' No se tocó nada: la versión que tenías sigue como estaba.'
735
+ );
736
+ }
737
+
574
738
  // El cwd no puede estar dentro de lo que npm va a borrar: en Windows un directorio
575
739
  // que es el cwd de un proceso vivo no se puede eliminar, y ese proceso es este.
576
740
  try {
@@ -731,6 +895,7 @@ function cmdHelp() {
731
895
  glassnote arranca el cliente
732
896
  glassnote start --detach lo arranca y libera la terminal
733
897
  glassnote update [version] actualiza (por defecto, la última)
898
+ glassnote pair da de alta el equipo con la API key de la organización
734
899
  glassnote uninstall quita autoarranque, ~/.glassnote y la línea del rc
735
900
  glassnote status versión, modo, rutas y estado del autoarranque
736
901
  glassnote fix-sandbox (Linux) restaura el sandbox de Chromium; pide sudo una vez
@@ -738,6 +903,26 @@ function cmdHelp() {
738
903
  Banderas de install:
739
904
  --no-path no toca el rc; imprime la línea para pegarla
740
905
  --no-start instala y no arranca el cliente
906
+
907
+ Emparejamiento desatendido (install y pair) — para dar de alta muchos equipos sin que
908
+ nadie escriba el código en la web:
909
+ --org-id <id> organización a la que entra el equipo
910
+ --api-key <clave> API key de esa organización
911
+ --device-name <n> nombre del equipo (por defecto, el hostname)
912
+ --user-name <n> persona a cargo (por defecto, el usuario del sistema)
913
+ --api-url <url> otra API (por defecto, ${pairing.API_URL_POR_DEFECTO})
914
+ --ws-url <url> fuerza el websocket que guarda el equipo
915
+
916
+ El websocket NO se elige acá: lo decide la API que atiende el alta, porque el token que
917
+ emite sólo vale en el suyo. Para dar de alta contra otro servidor se cambia --api-url y el
918
+ websocket viene de vuelta solo. --ws-url es para el caso en que la API anuncia una
919
+ dirección a la que el equipo no llega (IP de LAN, proxy inverso con otro nombre).
920
+
921
+ Las mismas se pueden pasar por entorno, para despliegues que no admiten argumentos:
922
+ GLASSNOTE_ORG_ID, GLASSNOTE_API_KEY, GLASSNOTE_DEVICE_NAME, GLASSNOTE_USER_NAME,
923
+ GLASSNOTE_API_URL, GLASSNOTE_WS_URL.
924
+
925
+ npx @glassnote/client install --org-id 55 --api-key abc123 --device-name "Caja 01"
741
926
  `);
742
927
  return 0;
743
928
  }
@@ -762,6 +947,9 @@ function main() {
762
947
  });
763
948
  case 'install':
764
949
  return cmdInstall(rest);
950
+ case 'pair':
951
+ case 'enroll':
952
+ return cmdPair(rest);
765
953
  case 'update':
766
954
  case 'upgrade':
767
955
  return cmdUpdate(rest);
@@ -787,7 +975,20 @@ function main() {
787
975
  // Requerido desde un test, el archivo NO debe correr nada: solo expone las piezas que
788
976
  // se comprueban. Como CLI se comporta igual que siempre.
789
977
  if (require.main === module) {
790
- process.exit(main());
978
+ // `install` y `pair` hablan con la API, asi que main() puede devolver una promesa.
979
+ // Salir con process.exit() sin esperarla cortaria el alta a la mitad.
980
+ const resultado = main();
981
+ if (resultado && typeof resultado.then === 'function') {
982
+ resultado.then(
983
+ (codigo) => process.exit(codigo),
984
+ (error) => {
985
+ console.error(`glassnote: ${error && error.message ? error.message : error}`);
986
+ process.exit(1);
987
+ }
988
+ );
989
+ } else {
990
+ process.exit(resultado);
991
+ }
791
992
  } else {
792
- module.exports = { npmInvocation, npmEnv, toSpec };
993
+ module.exports = { npmInvocation, npmEnv, toSpec, guionCerrarCliente, cerrarClienteWindows };
793
994
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glassnote/client",
3
- "version": "2.4.10",
3
+ "version": "2.4.11",
4
4
  "description": "GlassNote — cliente de escritorio de notas superpuestas. Se instala con npx, sin instaladores nativos ni firmas.",
5
5
  "main": "main.js",
6
6
  "publishConfig": {
@@ -36,6 +36,7 @@
36
36
  "localserver.js",
37
37
  "logUtilities.js",
38
38
  "userData.js",
39
+ "pairing.js",
39
40
  "INSTALACION.md",
40
41
  "README.md"
41
42
  ],
@@ -54,7 +55,9 @@
54
55
  "prepublishOnly": "node scripts/check-publish.js",
55
56
  "start-electron": "electron .",
56
57
  "test-update": "node scripts/test-update.js",
57
- "test": "node scripts/test-update.js"
58
+ "test-pairing": "node scripts/test-pairing.js",
59
+ "test-cli": "node scripts/test-cli.js",
60
+ "test": "node scripts/test-cli.js && node scripts/test-pairing.js && node scripts/test-update.js"
58
61
  },
59
62
  "keywords": [
60
63
  "glassnote",
package/pairing.js ADDED
@@ -0,0 +1,338 @@
1
+ // Emparejamiento desatendido: el equipo se da de alta solo con la API key de la
2
+ // organizacion, sin que nadie mire la pantalla.
3
+ //
4
+ // El emparejamiento de siempre es a dos manos: el cliente muestra un keycode y una
5
+ // persona lo escribe en la web. Para un equipo esta bien. Para trescientos —una cadena
6
+ // que abre locales, un parque que se reinstala entero— no es un flujo, es una tarde
7
+ // entera y un error de tipeo cada veinte equipos. Aca el instalador ya trae la API key
8
+ // de la organizacion, asi que el propio cliente pide su alta a la API y se guarda el
9
+ // token con el que se conecta. La instalacion termina con el equipo ya en el tablero.
10
+ //
11
+ // Vive fuera de bin/glassnote.js a proposito: decidir QUE se le pide a la API es una
12
+ // funcion pura y se puede comprobar en cualquier maquina; hacerlo de verdad, no.
13
+ 'use strict';
14
+
15
+ const os = require('os');
16
+ const http = require('http');
17
+ const https = require('https');
18
+ const { URL } = require('url');
19
+
20
+ const API_URL_POR_DEFECTO = 'https://glassnoteapi.intermark.ec';
21
+ const TIMEOUT_MS = 20000;
22
+
23
+ // Un solo sitio donde vive la correspondencia bandera <-> variable de entorno. Las
24
+ // variables existen porque un despliegue por GPO o por MDM muchas veces no puede pasar
25
+ // argumentos, pero si puede poner entorno.
26
+ const OPCIONES = [
27
+ { clave: 'orgId', banderas: ['--org-id', '--organization-id'], env: 'GLASSNOTE_ORG_ID' },
28
+ { clave: 'apiKey', banderas: ['--api-key', '--apikey'], env: 'GLASSNOTE_API_KEY' },
29
+ { clave: 'deviceName', banderas: ['--device-name', '--name'], env: 'GLASSNOTE_DEVICE_NAME' },
30
+ { clave: 'userName', banderas: ['--user-name', '--owner-name', '--owner'], env: 'GLASSNOTE_USER_NAME' },
31
+ { clave: 'apiUrl', banderas: ['--api-url'], env: 'GLASSNOTE_API_URL' },
32
+ { clave: 'wsUrl', banderas: ['--ws-url', '--server'], env: 'GLASSNOTE_WS_URL' },
33
+ ];
34
+
35
+ // Acepta `--org-id 55` y `--org-id=55`: los dos aparecen en los scripts de despliegue que
36
+ // escribe la gente, y fallar por la forma del signo igual es una perdida de tiempo.
37
+ function leerOpciones(args, env) {
38
+ const argv = Array.isArray(args) ? args : [];
39
+ const entorno = env || {};
40
+ const opciones = {};
41
+
42
+ for (const { clave, banderas, env: nombreEnv } of OPCIONES) {
43
+ let valor;
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const a = argv[i];
46
+ const conIgual = banderas.find((b) => a.startsWith(`${b}=`));
47
+ if (conIgual) {
48
+ valor = a.slice(conIgual.length + 1);
49
+ continue;
50
+ }
51
+ if (banderas.includes(a) && i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
52
+ valor = argv[i + 1];
53
+ }
54
+ }
55
+ if (valor === undefined && entorno[nombreEnv]) valor = entorno[nombreEnv];
56
+ if (valor !== undefined) opciones[clave] = String(valor).trim();
57
+ }
58
+
59
+ return opciones;
60
+ }
61
+
62
+ // ¿Pidieron emparejar? Basta con que venga la API key: sin ella no hay nada que hacer, y
63
+ // con ella el resto tiene valores por defecto razonables.
64
+ function pidenEmparejar(opciones) {
65
+ return !!(opciones && opciones.apiKey);
66
+ }
67
+
68
+ // El nombre por defecto es el del equipo, no uno generico. En un despliegue masivo el
69
+ // mismo comando corre en todas las maquinas: si el nombre no sale del propio equipo, el
70
+ // tablero termina con trescientos "Unnamed Device" y hay que renombrarlos a mano, que es
71
+ // justo el trabajo que esto viene a evitar.
72
+ function nombrePorDefecto() {
73
+ try {
74
+ return os.hostname();
75
+ } catch (error) {
76
+ return '';
77
+ }
78
+ }
79
+
80
+ function usuarioPorDefecto() {
81
+ try {
82
+ return os.userInfo().username;
83
+ } catch (error) {
84
+ return '';
85
+ }
86
+ }
87
+
88
+ function ipsLocales() {
89
+ const ips = [];
90
+ try {
91
+ const interfaces = os.networkInterfaces();
92
+ for (const nombre of Object.keys(interfaces)) {
93
+ for (const iface of interfaces[nombre] || []) {
94
+ if (!iface.internal && iface.address) ips.push(iface.address);
95
+ }
96
+ }
97
+ } catch (error) {
98
+ /* sin red que enumerar: se manda vacio */
99
+ }
100
+ return ips;
101
+ }
102
+
103
+ // Lo que se le manda a POST /devices/enroll. Separado del envio para poder comprobarlo.
104
+ function cuerpoDeAlta(opciones, uuid) {
105
+ return {
106
+ organizationId: opciones.orgId,
107
+ uuid,
108
+ name: opciones.deviceName || nombrePorDefecto() || uuid,
109
+ ownerName: opciones.userName || usuarioPorDefecto() || undefined,
110
+ os: process.platform,
111
+ localIps: JSON.stringify(ipsLocales()),
112
+ };
113
+ }
114
+
115
+ function urlDeAlta(opciones) {
116
+ const base = (opciones.apiUrl || API_URL_POR_DEFECTO).replace(/\/+$/, '');
117
+ return `${base}/devices/enroll`;
118
+ }
119
+
120
+ function postJson(url, headers, cuerpo) {
121
+ return new Promise((resolve, reject) => {
122
+ let destino;
123
+ try {
124
+ destino = new URL(url);
125
+ } catch (error) {
126
+ return reject(new Error(`la URL de la API no es valida: ${url}`));
127
+ }
128
+
129
+ const datos = Buffer.from(JSON.stringify(cuerpo), 'utf8');
130
+ const transporte = destino.protocol === 'http:' ? http : https;
131
+ const req = transporte.request(
132
+ destino,
133
+ {
134
+ method: 'POST',
135
+ headers: Object.assign(
136
+ {
137
+ 'content-type': 'application/json',
138
+ 'content-length': datos.length,
139
+ },
140
+ headers
141
+ ),
142
+ },
143
+ (res) => {
144
+ const trozos = [];
145
+ res.on('data', (t) => trozos.push(t));
146
+ res.on('end', () => {
147
+ const texto = Buffer.concat(trozos).toString('utf8');
148
+ let json = null;
149
+ try {
150
+ json = JSON.parse(texto);
151
+ } catch (error) {
152
+ /* la API contesto algo que no es JSON; se conserva el texto */
153
+ }
154
+ resolve({ status: res.statusCode, json, texto });
155
+ });
156
+ }
157
+ );
158
+
159
+ req.setTimeout(TIMEOUT_MS, () => {
160
+ req.destroy(new Error(`la API no contesto en ${TIMEOUT_MS / 1000} segundos`));
161
+ });
162
+ req.on('error', reject);
163
+ req.end(datos);
164
+ });
165
+ }
166
+
167
+ // La API contesta el error dentro del JSON y `message` puede ser texto o lista (las
168
+ // validaciones de Nest vienen en lista). Un instalador que dice "fallo con 403" y nada
169
+ // mas obliga a abrir el codigo; se saca el motivo de donde este.
170
+ function motivoDelError(respuesta) {
171
+ const m = respuesta.json && respuesta.json.message;
172
+ let motivo = '';
173
+ if (Array.isArray(m)) motivo = m.join('; ');
174
+ else if (typeof m === 'string') motivo = m;
175
+
176
+ // Cuando el guard rechaza la clave, Nest contesta su "Forbidden resource" generico,
177
+ // que no le dice nada a quien esta desplegando. El unico motivo posible aca es la
178
+ // clave, asi que se dice cual es.
179
+ if (respuesta.status === 401 || (respuesta.status === 403 && /forbidden resource/i.test(motivo))) {
180
+ return 'la API key no es valida o ya expiro';
181
+ }
182
+
183
+ if (motivo) return motivo;
184
+ if (respuesta.texto) return respuesta.texto.slice(0, 300);
185
+ return `la API contesto ${respuesta.status}`;
186
+ }
187
+
188
+ /**
189
+ * Da de alta el equipo y deja escrito en el archivo de usuario todo lo que la app
190
+ * necesita para conectarse sola en el primer arranque: el servidor y su token.
191
+ *
192
+ * `userData` entra por parametro y no por require: este archivo se carga desde el CLI,
193
+ * que corre tambien donde el arbol de dependencias esta a medias.
194
+ */
195
+ async function emparejar(opciones, userData) {
196
+ if (!opciones.apiKey) throw new Error('falta la API key de la organizacion (--api-key)');
197
+ if (!opciones.orgId) throw new Error('falta el id de la organizacion (--org-id)');
198
+
199
+ const uuid = userData.deviceUUID();
200
+ const respuesta = await postJson(
201
+ urlDeAlta(opciones),
202
+ { 'x-api-key': opciones.apiKey },
203
+ cuerpoDeAlta(opciones, uuid)
204
+ );
205
+
206
+ if (respuesta.status !== 200 && respuesta.status !== 201) {
207
+ throw new Error(motivoDelError(respuesta));
208
+ }
209
+
210
+ const datos = respuesta.json || {};
211
+ if (!datos.refreshToken) {
212
+ throw new Error('la API acepto el alta pero no devolvio token');
213
+ }
214
+
215
+ const server = servidorEfectivo(opciones, datos.server);
216
+ guardarAlta(userData, datos, server);
217
+
218
+ return {
219
+ uuid,
220
+ deviceId: datos.deviceId,
221
+ organizationId: datos.organizationId,
222
+ name: datos.name,
223
+ server,
224
+ servidorAnunciado: datos.server || '',
225
+ servidorForzado: !!opciones.wsUrl,
226
+ mismoOrigenQueLaApi: mismoOrigen(urlDeAlta(opciones), server),
227
+ yaEstaba: !!datos.alreadyEnrolled,
228
+ };
229
+ }
230
+
231
+ /**
232
+ * A que websocket se conecta el equipo.
233
+ *
234
+ * Normalmente lo decide la API, no el instalador: el token que acaba de emitir solo vale
235
+ * en SU websocket, asi que apuntar a otro sitio solo consigue un equipo dado de alta que
236
+ * no conecta. Por eso para hablar con otro servidor lo que se cambia es `--api-url`, y el
237
+ * websocket viene de vuelta solo.
238
+ *
239
+ * El override existe para el unico caso que eso no cubre: que la API anuncie una
240
+ * direccion a la que el equipo no llega —una IP de LAN detras de un NAT, un proxy inverso
241
+ * publicado con otro nombre, DNS partido—. Ahi el alta sale bien y los trescientos
242
+ * equipos guardan una URL muerta, sin ninguna senal en el momento de instalar. Lo
243
+ * correcto es arreglar WEBSOCKET_URL en la API; esto es la salida cuando no se puede.
244
+ */
245
+ function servidorEfectivo(opciones, servidorDeLaApi) {
246
+ const forzado = (opciones.wsUrl || '').trim();
247
+ if (forzado) {
248
+ if (!/^wss?:\/\//i.test(forzado)) {
249
+ throw new Error(`--ws-url tiene que empezar por ws:// o wss:// (llego "${forzado}")`);
250
+ }
251
+ return forzado;
252
+ }
253
+ if (!servidorDeLaApi) {
254
+ throw new Error(
255
+ 'la API no devolvio websocket (le falta WEBSOCKET_URL); se puede forzar con --ws-url'
256
+ );
257
+ }
258
+ return servidorDeLaApi;
259
+ }
260
+
261
+ // El cliente saca el origen HTTP de la API a partir de esta misma URL de websocket
262
+ // (cambiandole el esquema), asi que cuando el host no coincide con el de --api-url hay
263
+ // algo raro y conviene decirlo. No es un error: un despliegue puede separarlos a
264
+ // proposito.
265
+ function mismoOrigen(urlApi, urlWebsocket) {
266
+ try {
267
+ return new URL(urlApi).host === new URL(urlWebsocket).host;
268
+ } catch (error) {
269
+ return true; // si no se puede comparar, no se avisa de nada
270
+ }
271
+ }
272
+
273
+ // Se escribe exactamente lo mismo que dejaba el emparejamiento por keycode, y por las
274
+ // mismas claves: para la app las dos altas son indistinguibles, asi que no hay un segundo
275
+ // camino de conexion que mantener.
276
+ function guardarAlta(userData, datos, servidor) {
277
+ // El servidor va aparte del resto de la respuesta porque puede venir forzado desde la
278
+ // linea de comandos. Y tiene que ser EL MISMO en las tres claves: la app busca el
279
+ // token por la URL con la que se conecta, asi que guardarlo bajo otra lo deja
280
+ // arrancando y sin sesion, que es un fallo mudo.
281
+ const server = servidor || datos.server;
282
+
283
+ const servers = userData.get('servers');
284
+ const lista = Array.isArray(servers) ? servers.slice() : [];
285
+ if (!lista.includes(server)) lista.push(server);
286
+ userData.set('servers', lista);
287
+
288
+ userData.set('refreshTokens', server, datos.refreshToken);
289
+ if (datos.refreshTokenHash) {
290
+ userData.set('refreshTokenHashes', server, datos.refreshTokenHash);
291
+ }
292
+
293
+ // El token de acceso viejo pertenece al alta anterior: si se queda, la app intenta
294
+ // conectarse con el antes que con el refresh nuevo y le rebota.
295
+ const accessTokens = userData.get('accessTokens');
296
+ if (accessTokens && typeof accessTokens === 'object' && accessTokens[server]) {
297
+ userData.remove('accessTokens', server);
298
+ }
299
+ }
300
+
301
+ // Todas las banderas de emparejamiento llevan valor. El CLI lo necesita para no
302
+ // confundir ese valor con un argumento suelto: `install --org-id 55` no instala la
303
+ // version "55".
304
+ const BANDERAS_CON_VALOR = OPCIONES.reduce((todas, o) => todas.concat(o.banderas), []);
305
+
306
+ // El primer argumento suelto, saltando banderas y los valores que se llevan. Sin esto,
307
+ // `glassnote install --api-key abc` intentaba instalar la version "abc".
308
+ function argumentoPosicional(args) {
309
+ const argv = Array.isArray(args) ? args : [];
310
+ for (let i = 0; i < argv.length; i++) {
311
+ const a = argv[i];
312
+ if (a.startsWith('-')) {
313
+ if (BANDERAS_CON_VALOR.includes(a)) i++; // el valor va aparte, se salta
314
+ continue;
315
+ }
316
+ return a;
317
+ }
318
+ return undefined;
319
+ }
320
+
321
+ module.exports = {
322
+ API_URL_POR_DEFECTO,
323
+ BANDERAS_CON_VALOR,
324
+ argumentoPosicional,
325
+ OPCIONES,
326
+ leerOpciones,
327
+ pidenEmparejar,
328
+ cuerpoDeAlta,
329
+ urlDeAlta,
330
+ motivoDelError,
331
+ guardarAlta,
332
+ servidorEfectivo,
333
+ mismoOrigen,
334
+ emparejar,
335
+ nombrePorDefecto,
336
+ usuarioPorDefecto,
337
+ ipsLocales,
338
+ };