@dotrino/vaultd 0.156.0 → 0.158.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -128,10 +128,49 @@ firmado**. Se lee con `dotrino-vault activity`.
128
128
 
129
129
  ## Instalación
130
130
 
131
- Tres vías, y todas dejan la misma bóveda: **instalador de Linux** (queda como
132
- servicio), **un comando** (`npx`, en cualquier sistema con Node) y **Docker**. En
133
- Linux la primera es la cómoda; en Windows y macOS todavía no hay instalador de un
134
- clic, así que van por las otras dos.
131
+ Todas las vías dejan la misma bóveda: **instalador** de Linux (`.deb`, `.rpm`,
132
+ tarball), de **Windows** (`-setup.exe`) y de **macOS** (`Dotrino Vault.app`), **un
133
+ comando** (`npx`, en cualquier sistema con Node) y **Docker**. Los instaladores traen
134
+ además **la ventana** (ver abajo); `npx` y Docker, la TUI.
135
+
136
+ ### La ventana (`desktop/`)
137
+
138
+ Una app de escritorio con **las mismas pantallas que la TUI**: bóvedas, aparatos,
139
+ emparejar (con el QR como imagen), permisos, cajones y variables, inicios de sesión, mi
140
+ perfil, llaves de seguridad y ajustes. No reimplementa nada: lanza `dotrino-vaultd
141
+ --gui-bridge` y pinta el modelo que le manda el puente (`src/tui/gui.js`), que corre la
142
+ misma sesión que la TUI. Una acción nueva en la TUI aparece en la ventana sin tocarla. Sin
143
+ puertos: el canal son las tuberías del proceso hijo. Rust + `iced`, con el tema del
144
+ ecosistema (Quicksand/Comfortaa, claro u oscuro según el sistema).
145
+
146
+ Busca la bóveda al lado de su ejecutable (así la ponen los instaladores), luego en el
147
+ `PATH` (`dotrino-vaultd`, o el `dotrino-vault` de npm). Para desarrollo:
148
+ `DOTRINO_VAULT_BRIDGE="node bin/dotrino-vault.js" cargo run` en `desktop/`.
149
+
150
+ **Windows — instalador:** `dotrino-vault-X.Y.Z-windows-x64-setup.exe` desde
151
+ [Releases](https://github.com/imdotrino/dotrino-vault/releases/latest). Se instala para
152
+ tu usuario, sin administrador, en `%LOCALAPPDATA%\Programs\Dotrino Vault`, con su entrada
153
+ en el menú Inicio, y deja la bóveda **arrancando al iniciar sesión** (una entrada `Run`
154
+ que lanza `dotrino-vault-desktop.exe --start-daemon`, sin ventana). Se cambia en Ajustes o
155
+ con `dotrino-vault service autostart on|off`. Desinstalar no borra tus datos.
156
+
157
+ **macOS — `Dotrino Vault.app`:** el `.zip` universal (Apple Silicon e Intel) desde
158
+ [Releases](https://github.com/imdotrino/dotrino-vault/releases/latest); arrástrala a
159
+ Aplicaciones. Dentro van la ventana y la bóveda. Para que arranque al iniciar sesión, en
160
+ Ajustes (un LaunchAgent de tu usuario, `~/Library/LaunchAgents/com.dotrino.vault.plist`).
161
+
162
+ **Se actualizan solas, como en Linux** (CONVENCIONES §15): una vez al día miran si hay versión
163
+ nueva, bajan el instalador (Windows) o la app (macOS), lo **verifican contra la atestación de
164
+ sigstore** del release —dentro del propio binario, sin `gh`— y lo instalan. En Windows el
165
+ instalador cierra la bóveda, la cambia y la vuelve a arrancar (y vuelve a abrir la ventana si
166
+ estaba abierta); en macOS se cambia la app entera y la levanta launchd o, suelta, un
167
+ relanzador. La aprobación opcional (`update --approval on`) vale igual.
168
+
169
+ > El instalador de Windows **no lleva firma de Microsoft, y no la va a llevar** (decisión del
170
+ > dueño): Windows enseña el aviso de SmartScreen («editor desconocido»). La app de macOS
171
+ > todavía no lleva la firma de Apple: Gatekeeper avisa la primera vez (clic derecho → Abrir).
172
+ > Lo que prueba de dónde salieron es la atestación de sigstore:
173
+ > `gh attestation verify <archivo> --repo imdotrino/dotrino-vault`.
135
174
 
136
175
  **Ubuntu / Debian — `.deb`:** descarga el `.deb` (versionado) desde
137
176
  [Releases](https://github.com/imdotrino/dotrino-vault/releases/latest) y haz doble
@@ -196,7 +235,7 @@ npx -y @dotrino/vaultd # Node ≥ 20
196
235
  npx -y @dotrino/vaultd --tui # la bóveda y su pantalla de control en la misma ventana
197
236
  ```
198
237
 
199
- Es la vía normal en **Windows y macOS**, y sirve igual en Linux. Arranca en primer
238
+ Sirve en cualquier sistema (y es la vía en ARM fuera de Docker). Arranca en primer
200
239
  plano: la bóveda vive mientras dejes esa ventana abierta. Los comandos de control se
201
240
  anteponen con `npx -p @dotrino/vaultd` (por ejemplo, `npx -p @dotrino/vaultd
202
241
  dotrino-vault pair`). Si no tienes Node, el instalador del ecosistema lo baja en tu
@@ -228,9 +267,9 @@ recupera.
228
267
 
229
268
  > Sin firma de código: tu sistema puede advertir que el binario no está firmado. Es
230
269
  > autohospedado y de código abierto; en Linux solo necesita permiso de ejecución (el
231
- > instalador lo da). Hay un build de Windows (`packaging/build-win.sh`) pero **no se
232
- > publica**: se construye desde Linux y no se ha probado en un Windows de verdad, y
233
- > la inyección del blob invalida la firma de Microsoft, así que saltaría SmartScreen.
270
+ > instalador lo da). El daemon de Windows (`packaging/build-win.sh`) se construye desde
271
+ > Linux y va dentro del instalador de Windows; el de macOS (`packaging/build-mac.sh`) se
272
+ > construye en un Mac y va dentro de la app.
234
273
 
235
274
  **Dónde vive todo y cómo se para.** Tus datos —llave incluida— viven en
236
275
  `~/.local/share/dotrino/vault` (Linux y macOS), `%LOCALAPPDATA%\Dotrino\vault`
@@ -848,8 +887,10 @@ ed25519 en formato OpenSSH (sin frase: la bóveda es el candado) y RSA/P-256 en
848
887
  ## Alcance
849
888
 
850
889
  - **v1 (este):** daemon headless en Node, **multi-perfil**. En **Linux** queda como
851
- servicio `systemd --user` (binario único Node SEA: `.deb` y tarball); en **Windows y
852
- macOS** se corre con `npx`, en primer plano; y en cualquier sistema, con **Docker**
890
+ servicio `systemd --user` (binario único Node SEA: `.deb`, `.rpm` y tarball); en
891
+ **Windows** con un instalador por usuario y en **macOS** como `Dotrino Vault.app`, los
892
+ dos con arranque al iniciar sesión; en los tres, con **una ventana** que tiene las
893
+ pantallas de la TUI (`desktop/`); y en cualquier sistema, con `npx` o **Docker**
853
894
  (imagen amd64/arm64 en GHCR). Identidad **cifrada en reposo y ligada a esta
854
895
  máquina**. Emparejamiento endurecido con los dos caminos de vinculación, acta del
855
896
  perfil (miembros, capacidades y `cn` de servicio), firma delegada, renovación
@@ -858,9 +899,8 @@ ed25519 en formato OpenSSH (sin frase: la bóveda es el candado) y RSA/P-256 en
858
899
  actividad. Contraseña opcional por perfil, **para editarlo** (verificador PBKDF2).
859
900
  - **v2:** cifrado en reposo **con la contraseña del perfil** (hoy la clave sale solo de
860
901
  la máquina) y atado a tu cuenta del sistema —DPAPI en Windows, Keychain en macOS— o
861
- al TPM; cifrar también los secretos y el store; **instalador de un clic para Windows
862
- y macOS**; UI de escritorio (Tauri) como cliente del daemon; firma de documentos con
863
- sellado de tiempo (`dotrino-signer`).
902
+ al TPM; cifrar también los secretos y el store; firma y notarización de Apple para la app de
903
+ macOS; firma de documentos con sellado de tiempo (`dotrino-signer`).
864
904
 
865
905
  ## Estructura
866
906
 
@@ -870,7 +910,9 @@ ed25519 en formato OpenSSH (sin frase: la bóveda es el candado) y RSA/P-256 en
870
910
  - `src/daemon.js` — modo servicio: `state.json`, control por archivos + señales, apagado limpio.
871
911
  - `src/ctl.js` — CLI de control (habla con el daemon por archivos + señales, sin socket).
872
912
  - `src/vaultControl.js` — API de control programática (misma vía que la CLI); la usa la TUI.
873
- - `src/tui/` — interfaz de terminal, sin dependencias (`term.js` primitivas ANSI/raw-mode; `app.js` pantallas; `i18n.js` los textos es/en).
913
+ - `src/tui/` — interfaz de terminal, sin dependencias (`term.js` primitivas ANSI/raw-mode; `app.js` pantallas; `i18n.js` los textos es/en; `gui.js` el puente de la ventana).
914
+ - `src/service.js` — arrancar, reiniciar y arrancar al iniciar sesión en Linux, Windows y macOS.
915
+ - `desktop/` — la ventana (Rust + `iced`): pinta lo que le manda `gui.js`.
874
916
  - `src/transport.js` — conexión headless al proxy + `identify` firmado (con el acta).
875
917
  - `src/threadStore.js` — el store del usuario: hilos, "recientes" y perfil (`threads.json`).
876
918
  - `src/store.js` — árbol de contenidos (`vault.json`, versionado). Hoy es un esqueleto: se lee, nadie escribe.
package/bin/sea-entry.js CHANGED
@@ -17,6 +17,10 @@ if (argv[0] === '--tui') {
17
17
  if (!process.stdout.isTTY) { console.error('la TUI necesita un terminal interactivo (TTY).'); process.exit(2) }
18
18
  import('../src/tui/app.js').then(({ runTui }) => runTui())
19
19
  .catch((e) => { console.error('error en la TUI:', e.message); process.exit(1) })
20
+ } else if (argv[0] === '--gui-bridge') {
21
+ // El canal de la ventana de escritorio (JSON por stdin/stdout, ver src/tui/gui.js).
22
+ import('../src/tui/gui.js').then(({ runGuiBridge }) => runGuiBridge()).then(() => process.exit(0))
23
+ .catch((e) => { console.error('error en el puente de la ventana:', e.message); process.exit(1) })
20
24
  } else if (argv[0] === '--ctl') {
21
25
  // CLI de control: NO toca la identidad ni el proxy; habla con el daemon vía
22
26
  // el dir de datos (state.json) y señales (SIGUSR1 para emparejar).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.156.0",
3
+ "version": "0.158.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -28,7 +28,8 @@
28
28
  "@dotrino/proxy-client": "^0.32.2",
29
29
  "@dotrino/roadmap": "0.4.13",
30
30
  "@dotrino/store": "0.11.0",
31
- "@dotrino/update": "0.5.1",
31
+ "@dotrino/update": "0.9.0",
32
+ "sigstore": "5.0.0",
32
33
  "ws": "^8.18.0"
33
34
  },
34
35
  "license": "MIT",
package/src/ctl.js CHANGED
@@ -102,7 +102,7 @@ const START_HINT = IN_DOCKER
102
102
  ? 'docker start dotrino-vault'
103
103
  : process.platform === 'linux'
104
104
  ? 'systemctl --user start dotrino-vault'
105
- : 'dotrino-vaultd (o: npx -y @dotrino/vaultd)'
105
+ : 'dotrino-vault service start (o: npx -y @dotrino/vaultd)'
106
106
  const RESTART_HINT = IN_DOCKER
107
107
  ? 'docker restart dotrino-vault'
108
108
  : process.platform === 'linux'
@@ -1855,7 +1855,7 @@ async function cmdUpdate (args = []) {
1855
1855
  : 'Esta bóveda se actualiza sola, sin pedir aprobación.')
1856
1856
  return
1857
1857
  }
1858
- const { latestRelease, assetFor, download, verifyArtifact, isNewer, isUserInstall, installUserRelease } = await import('./update.js')
1858
+ const { latestRelease, assetFor, download, verifyArtifact, isNewer, isUserInstall, installUserRelease, installKind, installRelease } = await import('./update.js')
1859
1859
  const soloMirar = args.includes('--check')
1860
1860
 
1861
1861
  process.stdout.write('Mirando qué hay publicado… ')
@@ -1874,6 +1874,38 @@ async function cmdUpdate (args = []) {
1874
1874
  console.log('%sHay %s y esta es la %s.%s', B, r.version, VERSION, Z)
1875
1875
  if (soloMirar) { console.log('Tráela con: dotrino-vault update'); return }
1876
1876
 
1877
+ // WINDOWS Y macOS: el instalador nuevo (Windows) o la app entera (macOS), verificados.
1878
+ const kind = installKind()
1879
+ if (kind === 'inno' || kind === 'app') {
1880
+ process.stdout.write('Bajando y comprobando la firma… ')
1881
+ const res = await installRelease(r, { kind })
1882
+ if (!res.ok) {
1883
+ console.log('')
1884
+ console.error('%sNO se instala nada.%s %s', R, Z, res.reason)
1885
+ if (res.file) console.error('El archivo quedó en %s, sin tocar nada.', res.file)
1886
+ process.exit(1)
1887
+ }
1888
+ console.log('firmada por el release de %s ✓', 'imdotrino/dotrino-vault')
1889
+ await noteUpdated(res.version)
1890
+ if (kind === 'inno') {
1891
+ // El instalador cierra la bóveda (y este mismo comando: es el mismo .exe), la cambia y
1892
+ // la vuelve a arrancar. Aquí no queda nada que esperar.
1893
+ console.log('Instalando la %s: la bóveda se cierra un momento y vuelve sola.', res.version)
1894
+ return
1895
+ }
1896
+ // macOS: la app nueva ya está en su sitio; falta reiniciar la que corre.
1897
+ const s = ipcRead(stateFile, null)
1898
+ if (s && alive(s.pid)) {
1899
+ const { restartDaemonService } = await import('./service.js')
1900
+ try { await restartDaemonService({ pid: s.pid, alive: () => alive(s.pid) }) } catch (e) {
1901
+ console.error('Instalada, pero no se pudo reiniciar: %s', e.message)
1902
+ process.exit(1)
1903
+ }
1904
+ await waitForService(res.version)
1905
+ } else console.log('Instalada la %s.', res.version)
1906
+ return
1907
+ }
1908
+
1877
1909
  // Instalación de usuario: se verifica y se instala aquí mismo, sin sudo.
1878
1910
  if (isUserInstall()) {
1879
1911
  process.stdout.write('Bajando y comprobando la firma… ')
@@ -2240,6 +2272,36 @@ function cmdLogs () {
2240
2272
  }
2241
2273
 
2242
2274
  // Import dinámico: así `dotrino-vault status` (el caso común) no carga la TUI.
2275
+ /**
2276
+ * `service start | autostart [on|off]`: arrancar el daemon y dejarlo arrancando al iniciar
2277
+ * sesión, en los tres sistemas (`src/service.js`). En Linux es la unidad de systemd; en
2278
+ * Windows una entrada de inicio de sesión; en macOS un LaunchAgent.
2279
+ */
2280
+ async function cmdService (rest) {
2281
+ const sv = await import('./service.js')
2282
+ const [sub, val] = rest
2283
+ if (sub === 'start') {
2284
+ // `readState()` sale si no hay state.json, y sin state.json es justo cuando hay que arrancar.
2285
+ const s = ipcRead(stateFile, null)
2286
+ if (s && alive(s.pid)) { console.log('ya está corriendo (pid ' + s.pid + ')'); return }
2287
+ const r = await sv.startDaemonService()
2288
+ if (!r.ok) { console.error('no se pudo arrancar: ' + r.err); process.exit(1) }
2289
+ console.log('arrancado · registro: ' + sv.daemonLogFile())
2290
+ return
2291
+ }
2292
+ if (sub === 'autostart') {
2293
+ if (val === 'on' || val === 'off') {
2294
+ const r = val === 'on' ? await sv.enableAutostart() : await sv.disableAutostart()
2295
+ if (!r.ok) { console.error('no se pudo: ' + r.err); process.exit(1) }
2296
+ } else if (val !== undefined) { console.error('uso: dotrino-vault service autostart [on|off]'); process.exit(2) }
2297
+ const st = await sv.autostartStatus()
2298
+ console.log('arranca al iniciar sesión: ' + (st.on === null ? 'no se sabe en este sistema' : st.on ? 'sí' : 'no') + (st.how !== 'none' ? ` (${st.how})` : ''))
2299
+ return
2300
+ }
2301
+ console.error('uso: dotrino-vault service start | autostart [on|off]')
2302
+ process.exit(2)
2303
+ }
2304
+
2243
2305
  async function cmdTui () {
2244
2306
  if (!process.stdout.isTTY) { console.error('la TUI necesita un terminal interactivo (TTY).'); process.exit(2) }
2245
2307
  const { runTui } = await import('./tui/app.js')
@@ -2250,6 +2312,10 @@ function help () {
2250
2312
  console.log(`dotrino-vault — control del certificador personal
2251
2313
 
2252
2314
  tui interfaz de terminal a pantalla completa (bóvedas, pares, secretos)
2315
+ service start arranca el servicio en segundo plano (sin ventana)
2316
+ service autostart [on|off]
2317
+ que arranque solo al iniciar sesión (systemd en Linux, una entrada
2318
+ de inicio en Windows, un LaunchAgent en macOS) · sin valor, dice cómo está
2253
2319
  status | info estado del servicio + fingerprint + el id de cada perfil
2254
2320
  update [--check] trae la versión nueva (verificando la firma) y espera a que el
2255
2321
  servicio la arranque · --check solo mira · --no-wait no espera
@@ -2485,6 +2551,10 @@ export async function runCtl (argv) {
2485
2551
  if (rest.includes('--help') || rest.includes('-h')) return help()
2486
2552
  switch (cmd) {
2487
2553
  case 'tui': return cmdTui()
2554
+ // El canal de la ventana de escritorio (`desktop/`). No es para personas: habla JSON por
2555
+ // stdin/stdout. Ver `src/tui/gui.js`.
2556
+ case 'gui-bridge': { await (await import('./tui/gui.js')).runGuiBridge(); process.exit(0) }
2557
+ case 'service': return cmdService(rest)
2488
2558
  case 'profile': return cmdProfile(rest)
2489
2559
  case 'unlock': return cmdUnlock(rest)
2490
2560
  case 'lock': return cmdLock()
package/src/daemon.js CHANGED
@@ -25,7 +25,7 @@ import { parseInvite } from '../lib/src/invite.js'
25
25
  import { scopeToCaps, scopeToCn } from '../lib/src/enroll.js'
26
26
  import { capScope } from '@dotrino/identity/acta'
27
27
  import { watchBinary } from './selfupdate.js'
28
- import { latestRelease, isNewer, CHECK_EVERY_MS, isUserInstall, installUserRelease } from './update.js'
28
+ import { latestRelease, isNewer, CHECK_EVERY_MS, installKind, installRelease } from './update.js'
29
29
  import { VERSION } from './version.js'
30
30
  import { readSettings } from './settings.js'
31
31
  import { approversOfAll, firstYes, readAsked, writeAsked, clearAsked, shouldAsk, resultOfNo, readRootTold, writeRootTold, shouldTellRoot } from './updateApproval.js'
@@ -1157,9 +1157,18 @@ export async function runDaemon () {
1157
1157
  }
1158
1158
  const conAvisos = quienAprueba.filter((a) => a.notifiedAt).length
1159
1159
 
1160
- // INSTALADA COMO PAQUETE DEL SISTEMA (.deb o .rpm en /usr/bin): el binario es de root y este
1161
- // proceso no puede tocarlo. Se dice, con las dos salidas, y no se intenta nada.
1162
- if (!isUserInstall()) {
1160
+ // CÓMO SE INSTALÓ decide si puede instalar ella misma (update.js, `installKind`): la de
1161
+ // usuario de Linux, el instalador de Windows y la app de macOS, sí. Por npm, Docker o un
1162
+ // checkout se actualiza por su propia vía: se dice en el log y nada más (no es «hace
1163
+ // falta root», y avisar eso al teléfono sería mentirle).
1164
+ const kind = installKind()
1165
+ if (kind === 'other') {
1166
+ if (avisadoOtra !== r.version) { avisadoOtra = r.version; console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · update it the way you installed it (npm, Docker, git)`) }
1167
+ return
1168
+ }
1169
+ // INSTALADA COMO PAQUETE DEL SISTEMA (.deb o .rpm en /usr/bin, o una app en una carpeta
1170
+ // que no es tuya): este proceso no puede tocarla. Se dice, con las salidas, y no se intenta nada.
1171
+ if (kind === 'system') {
1163
1172
  console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · installed as a system package, so it cannot update itself: \`dotrino-vault update\` (needs sudo), or move to a user install with the tarball's install.sh`)
1164
1173
  // …Y SE LE DICE A QUIEN APRUEBA, en todos los perfiles (dueño, 2026-10-08: «si se
1165
1174
  // requiere root por algún motivo, simplemente se notifica al aprobador que hay
@@ -1235,7 +1244,7 @@ export async function runDaemon () {
1235
1244
  }
1236
1245
  clearAsked(dir)
1237
1246
  if (pedidoSinRespuesta) { pedidoSinRespuesta = null; writeState() }
1238
- const res = await installUserRelease(r, { log: (m) => console.log(m) })
1247
+ const res = await installRelease(r, { log: (m) => console.log(m), kind, pid: process.pid })
1239
1248
  if (!res.ok) {
1240
1249
  // Lo que NO cuadra no se instala, y se dice por qué: el archivo queda a mano.
1241
1250
  console.log(`[vault] update to ${r.version} NOT installed: ${res.reason}${res.file ? ` (file left at ${res.file})` : ''}`)
@@ -1252,7 +1261,12 @@ export async function runDaemon () {
1252
1261
  try { ipcWrite(updatedFile, { v: 1, from: daemonVersion, to: r.version, at: Date.now() }) } catch (e) {
1253
1262
  console.log(`[vault] update: ${r.version} installed, but the note to tell the approvers could not be written: ${e.message}`)
1254
1263
  }
1255
- if (vigia) {
1264
+ // Windows y macOS: hay que irse YA. En Windows el instalador cierra la bóveda y la vuelve
1265
+ // a arrancar; en macOS la levanta launchd o el relanzador que dejó la instalación.
1266
+ if (res.then === 'exit') {
1267
+ console.log(`[vault] update: ${r.version} installed · leaving so the new one starts`)
1268
+ shutdown(`updated to ${r.version}`)
1269
+ } else if (vigia) {
1256
1270
  console.log(`[vault] update: ${r.version} installed · restarting now to run it`)
1257
1271
  shutdown(`updated to ${r.version}`)
1258
1272
  } else {
@@ -1263,6 +1277,7 @@ export async function runDaemon () {
1263
1277
  } finally { actualizando = false }
1264
1278
  }
1265
1279
  let actualizando = false
1280
+ let avisadoOtra = null
1266
1281
  let aprobada = null
1267
1282
  let avisandoRoot = null
1268
1283
 
package/src/service.js ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * service.js — arrancar, reiniciar y dejar arrancando al iniciar sesión el daemon del vault,
3
+ * en los tres sistemas.
4
+ *
5
+ * Lo usan la TUI y la ventana (las dos ofrecen «arrancar el servicio» cuando no está) y el
6
+ * CLI (`dotrino-vault service`). Antes la TUI llamaba a `systemctl --user` a pelo, así que en
7
+ * Windows y macOS el botón de arrancar solo sabía fallar.
8
+ *
9
+ * Linux systemd --user si la unidad está instalada (el .deb y el tarball la ponen);
10
+ * si no, el daemon suelto en segundo plano.
11
+ * Windows el daemon suelto, sin ventana. Al iniciar sesión: una entrada en
12
+ * HKCU\…\Run que lo lanza con `conhost --headless` (sin consola a la vista).
13
+ * macOS un LaunchAgent del usuario (`~/Library/LaunchAgents/com.dotrino.vault.plist`)
14
+ * que lo levanta al entrar y lo relanza si se cae; sin él, el daemon suelto.
15
+ *
16
+ * Todo es DEL USUARIO, sin permisos de administrador: es lo que deja que se actualice solo
17
+ * (CONVENCIONES §15).
18
+ */
19
+ import fs from 'node:fs'
20
+ import os from 'node:os'
21
+ import path from 'node:path'
22
+ import { execFile, spawn } from 'node:child_process'
23
+ import { fileURLToPath } from 'node:url'
24
+ import { dataDir, ensureDir } from './paths.js'
25
+ import { ipcRead } from './ipc.js'
26
+
27
+ const run = (cmd, args, timeout = 15000) => new Promise((resolve) => {
28
+ execFile(cmd, args, { timeout, windowsHide: true }, (err, so, se) => {
29
+ resolve({ ok: !err, out: String(so || ''), err: err ? (String(se || '').trim() || err.message) : '' })
30
+ })
31
+ })
32
+
33
+ /**
34
+ * Cómo se lanza el daemon desde este mismo proceso. En el binario autosuficiente es el
35
+ * propio ejecutable sin argumentos; desde npm, `node bin/dotrino-vaultd.js`.
36
+ */
37
+ export function daemonCommand () {
38
+ if (globalThis.__DOTRINO_SEA__) return { cmd: process.execPath, args: [] }
39
+ const here = path.dirname(fileURLToPath(import.meta.url))
40
+ return { cmd: process.execPath, args: [path.join(here, '..', 'bin', 'dotrino-vaultd.js')] }
41
+ }
42
+
43
+ /** Dónde escribe el daemon suelto lo que diría por consola. */
44
+ export const daemonLogFile = () => path.join(dataDir(), 'daemon.log')
45
+
46
+ /** Lanza el daemon suelto, en segundo plano y sin ventana, con su salida a `daemon.log`. */
47
+ export function spawnDaemon () {
48
+ const { cmd, args } = daemonCommand()
49
+ ensureDir(dataDir())
50
+ const fd = fs.openSync(daemonLogFile(), 'a', 0o600)
51
+ try {
52
+ const child = spawn(cmd, args, { detached: true, windowsHide: true, stdio: ['ignore', fd, fd] })
53
+ child.unref()
54
+ return { ok: true, err: '', pid: child.pid }
55
+ } catch (e) {
56
+ return { ok: false, err: e.message }
57
+ } finally { fs.closeSync(fd) }
58
+ }
59
+
60
+ /**
61
+ * Para el daemon suelto que esté corriendo (el de `spawnDaemon`), y espera a que se vaya.
62
+ * Hace falta antes de dárselo a un supervisor: el candado del dir de datos solo deja uno,
63
+ * y el del supervisor saldría con error y se relanzaría en bucle.
64
+ */
65
+ async function stopLooseDaemon () {
66
+ const pid = ipcRead(path.join(dataDir(), 'state.json'), null)?.pid
67
+ if (!pidAlive(pid)) return
68
+ try { process.kill(pid, 'SIGTERM') } catch (_) {}
69
+ await waitGone(pid)
70
+ }
71
+
72
+ const pidAlive = (pid) => { try { return !!pid && (process.kill(pid, 0) || true) } catch { return false } }
73
+
74
+ /** Espera a que el proceso se vaya (y suelte el candado del dir de datos). */
75
+ async function waitGone (pid) {
76
+ for (let n = 0; n < 40 && pidAlive(pid); n++) await new Promise((r) => setTimeout(r, 250))
77
+ if (pidAlive(pid)) throw new Error(`the running vault (pid ${pid}) did not stop`)
78
+ }
79
+
80
+ // ------------------------------- Linux ------------------------------------
81
+
82
+ async function hasSystemdUnit () {
83
+ // Con una carpeta de datos propia, la unidad (que tiene la suya) arrancaría OTRA bóveda.
84
+ if (process.platform !== 'linux' || process.env.DOTRINO_VAULT_DIR) return false
85
+ return (await run('systemctl', ['--user', 'cat', 'dotrino-vault'], 5000)).ok
86
+ }
87
+
88
+ // ------------------------------- macOS ------------------------------------
89
+
90
+ const LABEL = 'com.dotrino.vault'
91
+ export const launchAgentFile = () => path.join(os.homedir(), 'Library', 'LaunchAgents', LABEL + '.plist')
92
+ const guiDomain = () => `gui/${process.getuid?.() ?? 501}`
93
+
94
+ const xml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
95
+
96
+ /**
97
+ * El LaunchAgent: lo arranca al entrar y lo relanza siempre que se va, como `Restart=always`
98
+ * de systemd. Hace falta también cuando sale bien: tras instalar la versión nueva, la bóveda
99
+ * se va para que el agente arranque la nueva. Para pararla de verdad, `autostart off`.
100
+ */
101
+ export function launchAgentPlist ({ cmd, args }, log = daemonLogFile()) {
102
+ const argv = [cmd, ...args].map((a) => ` <string>${xml(a)}</string>`).join('\n')
103
+ return `<?xml version="1.0" encoding="UTF-8"?>
104
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
105
+ <plist version="1.0">
106
+ <dict>
107
+ <key>Label</key>
108
+ <string>${LABEL}</string>
109
+ <key>ProgramArguments</key>
110
+ <array>
111
+ ${argv}
112
+ </array>
113
+ <key>RunAtLoad</key>
114
+ <true/>
115
+ <key>KeepAlive</key>
116
+ <true/>
117
+ <key>StandardOutPath</key>
118
+ <string>${xml(log)}</string>
119
+ <key>StandardErrorPath</key>
120
+ <string>${xml(log)}</string>
121
+ <!-- Standard y no Background: en Background macOS frena la red y la CPU, y la bóveda
122
+ atiende al teléfono (firmas, aprobaciones). Con Background una actualización tardaba
123
+ más de 5 minutos en bajar y la verificación se caía por tiempo. -->
124
+ <key>ProcessType</key>
125
+ <string>Standard</string>
126
+ </dict>
127
+ </plist>
128
+ `
129
+ }
130
+
131
+ // ------------------------------- Windows ----------------------------------
132
+
133
+ const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
134
+ const RUN_VALUE = 'DotrinoVault'
135
+
136
+ /**
137
+ * La línea que se guarda en `Run`. `conhost --headless` (Windows 10 1809 en adelante) corre
138
+ * una aplicación de consola sin abrirle ventana: sin eso, el daemon sacaría una consola
139
+ * negra cada vez que entras.
140
+ */
141
+ export function windowsRunLine ({ cmd, args }) {
142
+ const q = (s) => `"${s}"`
143
+ // Con la ventana instalada al lado, ella lanza el daemon sin consola (`--start-daemon`):
144
+ // es una aplicación de ventanas y no depende de `conhost`.
145
+ const gui = path.join(path.dirname(cmd), 'dotrino-vault-desktop.exe')
146
+ if (globalThis.__DOTRINO_SEA__ && fs.existsSync(gui)) return `${q(gui)} --start-daemon`
147
+ return ['conhost.exe', '--headless', q(cmd), ...args.map(q)].join(' ')
148
+ }
149
+
150
+ // ------------------------------- común ------------------------------------
151
+
152
+ /** ¿Arranca solo al iniciar sesión? `null` = no se sabe en este sistema. */
153
+ export async function autostartStatus () {
154
+ if (process.platform === 'linux') {
155
+ if (!(await hasSystemdUnit())) return { on: false, how: 'none' }
156
+ const r = await run('systemctl', ['--user', 'is-enabled', 'dotrino-vault'], 5000)
157
+ return { on: r.out.trim() === 'enabled', how: 'systemd' }
158
+ }
159
+ if (process.platform === 'darwin') return { on: fs.existsSync(launchAgentFile()), how: 'launchd' }
160
+ if (process.platform === 'win32') {
161
+ const r = await run('reg', ['query', RUN_KEY, '/v', RUN_VALUE], 5000)
162
+ return { on: r.ok, how: 'run-key' }
163
+ }
164
+ return { on: null, how: 'unknown' }
165
+ }
166
+
167
+ /** Deja el daemon arrancando al iniciar sesión (y lo arranca ya, si no está). */
168
+ export async function enableAutostart () {
169
+ const dc = daemonCommand()
170
+ if (process.platform === 'linux') {
171
+ if (!(await hasSystemdUnit())) return { ok: false, err: 'no systemd user unit installed (install the .deb, .rpm or the tarball)' }
172
+ return run('systemctl', ['--user', 'enable', '--now', 'dotrino-vault'])
173
+ }
174
+ if (process.platform === 'darwin') {
175
+ const file = launchAgentFile()
176
+ ensureDir(path.dirname(file))
177
+ fs.writeFileSync(file, launchAgentPlist(dc), { mode: 0o644 })
178
+ await run('launchctl', ['bootout', `${guiDomain()}/${LABEL}`], 5000)
179
+ // Si ya hay un daemon suelto corriendo, el agente lo encontraría ocupado y se
180
+ // relanzaría en bucle: el candado del dir de datos solo deja uno. Se para y lo levanta
181
+ // el agente, que es quien lo cuidará desde ahora.
182
+ try { await stopLooseDaemon() } catch (e) { return { ok: false, err: e.message } }
183
+ return run('launchctl', ['bootstrap', guiDomain(), file])
184
+ }
185
+ if (process.platform === 'win32') {
186
+ return run('reg', ['add', RUN_KEY, '/v', RUN_VALUE, '/t', 'REG_SZ', '/d', windowsRunLine(dc), '/f'])
187
+ }
188
+ return { ok: false, err: `unsupported platform: ${process.platform}` }
189
+ }
190
+
191
+ /** Deja de arrancarlo al iniciar sesión. NO lo para: eso es otra decisión. */
192
+ export async function disableAutostart () {
193
+ if (process.platform === 'linux') return run('systemctl', ['--user', 'disable', 'dotrino-vault'])
194
+ if (process.platform === 'darwin') {
195
+ const file = launchAgentFile()
196
+ if (!fs.existsSync(file)) return { ok: true, err: '' }
197
+ // `bootout` también lo PARA. Se vuelve a lanzar suelto para no apagarle la bóveda a
198
+ // quien solo quería que no arrancara sola la próxima vez.
199
+ const wasRunning = (await run('launchctl', ['print', `${guiDomain()}/${LABEL}`], 5000)).ok
200
+ const pid = ipcRead(path.join(dataDir(), 'state.json'), null)?.pid
201
+ await run('launchctl', ['bootout', `${guiDomain()}/${LABEL}`], 5000)
202
+ fs.rmSync(file, { force: true })
203
+ if (!wasRunning) return { ok: true, err: '' }
204
+ // El suelto entra cuando el del agente ya soltó el candado; antes chocaría con él.
205
+ try { await waitGone(pid) } catch (e) { return { ok: false, err: e.message } }
206
+ return spawnDaemon()
207
+ }
208
+ if (process.platform === 'win32') {
209
+ const r = await run('reg', ['delete', RUN_KEY, '/v', RUN_VALUE, '/f'])
210
+ // Que no existiera no es un fallo: el resultado es el que se pedía.
211
+ return r.ok || /unable to find|no se puede encontrar|not find/i.test(r.err) ? { ok: true, err: '' } : r
212
+ }
213
+ return { ok: false, err: `unsupported platform: ${process.platform}` }
214
+ }
215
+
216
+ /** Arranca el daemon por el camino de este sistema. */
217
+ export async function startDaemonService () {
218
+ if (await hasSystemdUnit()) return run('systemctl', ['--user', 'start', 'dotrino-vault'], 8000)
219
+ if (process.platform === 'darwin' && fs.existsSync(launchAgentFile())) {
220
+ const r = await run('launchctl', ['kickstart', `${guiDomain()}/${LABEL}`], 8000)
221
+ if (r.ok) return r
222
+ // El agente está escrito pero no cargado (se borró a mano de launchctl): se carga.
223
+ return run('launchctl', ['bootstrap', guiDomain(), launchAgentFile()], 8000)
224
+ }
225
+ return spawnDaemon()
226
+ }
227
+
228
+ /**
229
+ * Reinicia el daemon (tras actualizar). Con systemd o launchd lo hace el supervisor; suelto,
230
+ * se le pide que se vaya y se lanza el nuevo cuando ya no está — dos a la vez no pueden,
231
+ * el candado del dir de datos lo impide.
232
+ */
233
+ export async function restartDaemonService ({ pid, alive } = {}) {
234
+ if (await hasSystemdUnit()) {
235
+ const r = await run('systemctl', ['--user', 'restart', 'dotrino-vault'], 20000)
236
+ if (!r.ok) throw new Error(r.err)
237
+ return
238
+ }
239
+ if (process.platform === 'darwin' && fs.existsSync(launchAgentFile())) {
240
+ const r = await run('launchctl', ['kickstart', '-k', `${guiDomain()}/${LABEL}`], 20000)
241
+ if (!r.ok) throw new Error(r.err)
242
+ return
243
+ }
244
+ if (pid) {
245
+ try { process.kill(pid, 'SIGTERM') } catch (_) {}
246
+ for (let n = 0; n < 40 && alive?.(); n++) await new Promise((r) => setTimeout(r, 250))
247
+ if (alive?.()) throw new Error('the old daemon did not stop')
248
+ }
249
+ const r = spawnDaemon()
250
+ if (!r.ok) throw new Error(r.err)
251
+ }