@dotrino/vaultd 0.156.0 → 0.157.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,43 @@ 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
+ > Ni el instalador de Windows ni la app de macOS llevan la firma de un desarrollador
163
+ > registrado: Windows enseña el aviso de SmartScreen («editor desconocido») y macOS el de
164
+ > Gatekeeper la primera vez (clic derecho → Abrir). Lo que prueba de dónde salieron es la
165
+ > atestación de sigstore: `gh attestation verify <archivo> --repo imdotrino/dotrino-vault`.
166
+ > En Windows y macOS la bóveda todavía **no se actualiza sola**: se instala la versión nueva
167
+ > encima.
135
168
 
136
169
  **Ubuntu / Debian — `.deb`:** descarga el `.deb` (versionado) desde
137
170
  [Releases](https://github.com/imdotrino/dotrino-vault/releases/latest) y haz doble
@@ -196,7 +229,7 @@ npx -y @dotrino/vaultd # Node ≥ 20
196
229
  npx -y @dotrino/vaultd --tui # la bóveda y su pantalla de control en la misma ventana
197
230
  ```
198
231
 
199
- Es la vía normal en **Windows y macOS**, y sirve igual en Linux. Arranca en primer
232
+ Sirve en cualquier sistema (y es la vía en ARM fuera de Docker). Arranca en primer
200
233
  plano: la bóveda vive mientras dejes esa ventana abierta. Los comandos de control se
201
234
  anteponen con `npx -p @dotrino/vaultd` (por ejemplo, `npx -p @dotrino/vaultd
202
235
  dotrino-vault pair`). Si no tienes Node, el instalador del ecosistema lo baja en tu
@@ -228,9 +261,9 @@ recupera.
228
261
 
229
262
  > Sin firma de código: tu sistema puede advertir que el binario no está firmado. Es
230
263
  > 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.
264
+ > instalador lo da). El daemon de Windows (`packaging/build-win.sh`) se construye desde
265
+ > Linux y va dentro del instalador de Windows; el de macOS (`packaging/build-mac.sh`) se
266
+ > construye en un Mac y va dentro de la app.
234
267
 
235
268
  **Dónde vive todo y cómo se para.** Tus datos —llave incluida— viven en
236
269
  `~/.local/share/dotrino/vault` (Linux y macOS), `%LOCALAPPDATA%\Dotrino\vault`
@@ -848,8 +881,10 @@ ed25519 en formato OpenSSH (sin frase: la bóveda es el candado) y RSA/P-256 en
848
881
  ## Alcance
849
882
 
850
883
  - **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**
884
+ servicio `systemd --user` (binario único Node SEA: `.deb`, `.rpm` y tarball); en
885
+ **Windows** con un instalador por usuario y en **macOS** como `Dotrino Vault.app`, los
886
+ dos con arranque al iniciar sesión; en los tres, con **una ventana** que tiene las
887
+ pantallas de la TUI (`desktop/`); y en cualquier sistema, con `npx` o **Docker**
853
888
  (imagen amd64/arm64 en GHCR). Identidad **cifrada en reposo y ligada a esta
854
889
  máquina**. Emparejamiento endurecido con los dos caminos de vinculación, acta del
855
890
  perfil (miembros, capacidades y `cn` de servicio), firma delegada, renovación
@@ -858,9 +893,9 @@ ed25519 en formato OpenSSH (sin frase: la bóveda es el candado) y RSA/P-256 en
858
893
  actividad. Contraseña opcional por perfil, **para editarlo** (verificador PBKDF2).
859
894
  - **v2:** cifrado en reposo **con la contraseña del perfil** (hoy la clave sale solo de
860
895
  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`).
896
+ al TPM; cifrar también los secretos y el store; que la bóveda de Windows y macOS se
897
+ actualice sola, como la de Linux; firma de Apple y de Microsoft; firma de documentos
898
+ con sellado de tiempo (`dotrino-signer`).
864
899
 
865
900
  ## Estructura
866
901
 
@@ -870,7 +905,9 @@ ed25519 en formato OpenSSH (sin frase: la bóveda es el candado) y RSA/P-256 en
870
905
  - `src/daemon.js` — modo servicio: `state.json`, control por archivos + señales, apagado limpio.
871
906
  - `src/ctl.js` — CLI de control (habla con el daemon por archivos + señales, sin socket).
872
907
  - `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).
908
+ - `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).
909
+ - `src/service.js` — arrancar, reiniciar y arrancar al iniciar sesión en Linux, Windows y macOS.
910
+ - `desktop/` — la ventana (Rust + `iced`): pinta lo que le manda `gui.js`.
874
911
  - `src/transport.js` — conexión headless al proxy + `identify` firmado (con el acta).
875
912
  - `src/threadStore.js` — el store del usuario: hilos, "recientes" y perfil (`threads.json`).
876
913
  - `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.157.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": {
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'
@@ -2240,6 +2240,36 @@ function cmdLogs () {
2240
2240
  }
2241
2241
 
2242
2242
  // Import dinámico: así `dotrino-vault status` (el caso común) no carga la TUI.
2243
+ /**
2244
+ * `service start | autostart [on|off]`: arrancar el daemon y dejarlo arrancando al iniciar
2245
+ * sesión, en los tres sistemas (`src/service.js`). En Linux es la unidad de systemd; en
2246
+ * Windows una entrada de inicio de sesión; en macOS un LaunchAgent.
2247
+ */
2248
+ async function cmdService (rest) {
2249
+ const sv = await import('./service.js')
2250
+ const [sub, val] = rest
2251
+ if (sub === 'start') {
2252
+ // `readState()` sale si no hay state.json, y sin state.json es justo cuando hay que arrancar.
2253
+ const s = ipcRead(stateFile, null)
2254
+ if (s && alive(s.pid)) { console.log('ya está corriendo (pid ' + s.pid + ')'); return }
2255
+ const r = await sv.startDaemonService()
2256
+ if (!r.ok) { console.error('no se pudo arrancar: ' + r.err); process.exit(1) }
2257
+ console.log('arrancado · registro: ' + sv.daemonLogFile())
2258
+ return
2259
+ }
2260
+ if (sub === 'autostart') {
2261
+ if (val === 'on' || val === 'off') {
2262
+ const r = val === 'on' ? await sv.enableAutostart() : await sv.disableAutostart()
2263
+ if (!r.ok) { console.error('no se pudo: ' + r.err); process.exit(1) }
2264
+ } else if (val !== undefined) { console.error('uso: dotrino-vault service autostart [on|off]'); process.exit(2) }
2265
+ const st = await sv.autostartStatus()
2266
+ 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})` : ''))
2267
+ return
2268
+ }
2269
+ console.error('uso: dotrino-vault service start | autostart [on|off]')
2270
+ process.exit(2)
2271
+ }
2272
+
2243
2273
  async function cmdTui () {
2244
2274
  if (!process.stdout.isTTY) { console.error('la TUI necesita un terminal interactivo (TTY).'); process.exit(2) }
2245
2275
  const { runTui } = await import('./tui/app.js')
@@ -2250,6 +2280,10 @@ function help () {
2250
2280
  console.log(`dotrino-vault — control del certificador personal
2251
2281
 
2252
2282
  tui interfaz de terminal a pantalla completa (bóvedas, pares, secretos)
2283
+ service start arranca el servicio en segundo plano (sin ventana)
2284
+ service autostart [on|off]
2285
+ que arranque solo al iniciar sesión (systemd en Linux, una entrada
2286
+ de inicio en Windows, un LaunchAgent en macOS) · sin valor, dice cómo está
2253
2287
  status | info estado del servicio + fingerprint + el id de cada perfil
2254
2288
  update [--check] trae la versión nueva (verificando la firma) y espera a que el
2255
2289
  servicio la arranque · --check solo mira · --no-wait no espera
@@ -2485,6 +2519,10 @@ export async function runCtl (argv) {
2485
2519
  if (rest.includes('--help') || rest.includes('-h')) return help()
2486
2520
  switch (cmd) {
2487
2521
  case 'tui': return cmdTui()
2522
+ // El canal de la ventana de escritorio (`desktop/`). No es para personas: habla JSON por
2523
+ // stdin/stdout. Ver `src/tui/gui.js`.
2524
+ case 'gui-bridge': { await (await import('./tui/gui.js')).runGuiBridge(); process.exit(0) }
2525
+ case 'service': return cmdService(rest)
2488
2526
  case 'profile': return cmdProfile(rest)
2489
2527
  case 'unlock': return cmdUnlock(rest)
2490
2528
  case 'lock': return cmdLock()
package/src/service.js ADDED
@@ -0,0 +1,247 @@
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
+ /** El LaunchAgent: lo arranca al entrar y lo relanza si se cae (no si se para a propósito). */
97
+ export function launchAgentPlist ({ cmd, args }, log = daemonLogFile()) {
98
+ const argv = [cmd, ...args].map((a) => ` <string>${xml(a)}</string>`).join('\n')
99
+ return `<?xml version="1.0" encoding="UTF-8"?>
100
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
101
+ <plist version="1.0">
102
+ <dict>
103
+ <key>Label</key>
104
+ <string>${LABEL}</string>
105
+ <key>ProgramArguments</key>
106
+ <array>
107
+ ${argv}
108
+ </array>
109
+ <key>RunAtLoad</key>
110
+ <true/>
111
+ <key>KeepAlive</key>
112
+ <dict>
113
+ <key>SuccessfulExit</key>
114
+ <false/>
115
+ </dict>
116
+ <key>StandardOutPath</key>
117
+ <string>${xml(log)}</string>
118
+ <key>StandardErrorPath</key>
119
+ <string>${xml(log)}</string>
120
+ <key>ProcessType</key>
121
+ <string>Background</string>
122
+ </dict>
123
+ </plist>
124
+ `
125
+ }
126
+
127
+ // ------------------------------- Windows ----------------------------------
128
+
129
+ const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
130
+ const RUN_VALUE = 'DotrinoVault'
131
+
132
+ /**
133
+ * La línea que se guarda en `Run`. `conhost --headless` (Windows 10 1809 en adelante) corre
134
+ * una aplicación de consola sin abrirle ventana: sin eso, el daemon sacaría una consola
135
+ * negra cada vez que entras.
136
+ */
137
+ export function windowsRunLine ({ cmd, args }) {
138
+ const q = (s) => `"${s}"`
139
+ // Con la ventana instalada al lado, ella lanza el daemon sin consola (`--start-daemon`):
140
+ // es una aplicación de ventanas y no depende de `conhost`.
141
+ const gui = path.join(path.dirname(cmd), 'dotrino-vault-desktop.exe')
142
+ if (globalThis.__DOTRINO_SEA__ && fs.existsSync(gui)) return `${q(gui)} --start-daemon`
143
+ return ['conhost.exe', '--headless', q(cmd), ...args.map(q)].join(' ')
144
+ }
145
+
146
+ // ------------------------------- común ------------------------------------
147
+
148
+ /** ¿Arranca solo al iniciar sesión? `null` = no se sabe en este sistema. */
149
+ export async function autostartStatus () {
150
+ if (process.platform === 'linux') {
151
+ if (!(await hasSystemdUnit())) return { on: false, how: 'none' }
152
+ const r = await run('systemctl', ['--user', 'is-enabled', 'dotrino-vault'], 5000)
153
+ return { on: r.out.trim() === 'enabled', how: 'systemd' }
154
+ }
155
+ if (process.platform === 'darwin') return { on: fs.existsSync(launchAgentFile()), how: 'launchd' }
156
+ if (process.platform === 'win32') {
157
+ const r = await run('reg', ['query', RUN_KEY, '/v', RUN_VALUE], 5000)
158
+ return { on: r.ok, how: 'run-key' }
159
+ }
160
+ return { on: null, how: 'unknown' }
161
+ }
162
+
163
+ /** Deja el daemon arrancando al iniciar sesión (y lo arranca ya, si no está). */
164
+ export async function enableAutostart () {
165
+ const dc = daemonCommand()
166
+ if (process.platform === 'linux') {
167
+ if (!(await hasSystemdUnit())) return { ok: false, err: 'no systemd user unit installed (install the .deb, .rpm or the tarball)' }
168
+ return run('systemctl', ['--user', 'enable', '--now', 'dotrino-vault'])
169
+ }
170
+ if (process.platform === 'darwin') {
171
+ const file = launchAgentFile()
172
+ ensureDir(path.dirname(file))
173
+ fs.writeFileSync(file, launchAgentPlist(dc), { mode: 0o644 })
174
+ await run('launchctl', ['bootout', `${guiDomain()}/${LABEL}`], 5000)
175
+ // Si ya hay un daemon suelto corriendo, el agente lo encontraría ocupado y se
176
+ // relanzaría en bucle: el candado del dir de datos solo deja uno. Se para y lo levanta
177
+ // el agente, que es quien lo cuidará desde ahora.
178
+ try { await stopLooseDaemon() } catch (e) { return { ok: false, err: e.message } }
179
+ return run('launchctl', ['bootstrap', guiDomain(), file])
180
+ }
181
+ if (process.platform === 'win32') {
182
+ return run('reg', ['add', RUN_KEY, '/v', RUN_VALUE, '/t', 'REG_SZ', '/d', windowsRunLine(dc), '/f'])
183
+ }
184
+ return { ok: false, err: `unsupported platform: ${process.platform}` }
185
+ }
186
+
187
+ /** Deja de arrancarlo al iniciar sesión. NO lo para: eso es otra decisión. */
188
+ export async function disableAutostart () {
189
+ if (process.platform === 'linux') return run('systemctl', ['--user', 'disable', 'dotrino-vault'])
190
+ if (process.platform === 'darwin') {
191
+ const file = launchAgentFile()
192
+ if (!fs.existsSync(file)) return { ok: true, err: '' }
193
+ // `bootout` también lo PARA. Se vuelve a lanzar suelto para no apagarle la bóveda a
194
+ // quien solo quería que no arrancara sola la próxima vez.
195
+ const wasRunning = (await run('launchctl', ['print', `${guiDomain()}/${LABEL}`], 5000)).ok
196
+ const pid = ipcRead(path.join(dataDir(), 'state.json'), null)?.pid
197
+ await run('launchctl', ['bootout', `${guiDomain()}/${LABEL}`], 5000)
198
+ fs.rmSync(file, { force: true })
199
+ if (!wasRunning) return { ok: true, err: '' }
200
+ // El suelto entra cuando el del agente ya soltó el candado; antes chocaría con él.
201
+ try { await waitGone(pid) } catch (e) { return { ok: false, err: e.message } }
202
+ return spawnDaemon()
203
+ }
204
+ if (process.platform === 'win32') {
205
+ const r = await run('reg', ['delete', RUN_KEY, '/v', RUN_VALUE, '/f'])
206
+ // Que no existiera no es un fallo: el resultado es el que se pedía.
207
+ return r.ok || /unable to find|no se puede encontrar|not find/i.test(r.err) ? { ok: true, err: '' } : r
208
+ }
209
+ return { ok: false, err: `unsupported platform: ${process.platform}` }
210
+ }
211
+
212
+ /** Arranca el daemon por el camino de este sistema. */
213
+ export async function startDaemonService () {
214
+ if (await hasSystemdUnit()) return run('systemctl', ['--user', 'start', 'dotrino-vault'], 8000)
215
+ if (process.platform === 'darwin' && fs.existsSync(launchAgentFile())) {
216
+ const r = await run('launchctl', ['kickstart', `${guiDomain()}/${LABEL}`], 8000)
217
+ if (r.ok) return r
218
+ // El agente está escrito pero no cargado (se borró a mano de launchctl): se carga.
219
+ return run('launchctl', ['bootstrap', guiDomain(), launchAgentFile()], 8000)
220
+ }
221
+ return spawnDaemon()
222
+ }
223
+
224
+ /**
225
+ * Reinicia el daemon (tras actualizar). Con systemd o launchd lo hace el supervisor; suelto,
226
+ * se le pide que se vaya y se lanza el nuevo cuando ya no está — dos a la vez no pueden,
227
+ * el candado del dir de datos lo impide.
228
+ */
229
+ export async function restartDaemonService ({ pid, alive } = {}) {
230
+ if (await hasSystemdUnit()) {
231
+ const r = await run('systemctl', ['--user', 'restart', 'dotrino-vault'], 20000)
232
+ if (!r.ok) throw new Error(r.err)
233
+ return
234
+ }
235
+ if (process.platform === 'darwin' && fs.existsSync(launchAgentFile())) {
236
+ const r = await run('launchctl', ['kickstart', '-k', `${guiDomain()}/${LABEL}`], 20000)
237
+ if (!r.ok) throw new Error(r.err)
238
+ return
239
+ }
240
+ if (pid) {
241
+ try { process.kill(pid, 'SIGTERM') } catch (_) {}
242
+ for (let n = 0; n < 40 && alive?.(); n++) await new Promise((r) => setTimeout(r, 250))
243
+ if (alive?.()) throw new Error('the old daemon did not stop')
244
+ }
245
+ const r = spawnDaemon()
246
+ if (!r.ok) throw new Error(r.err)
247
+ }