@dotrino/vaultd 0.155.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/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
+ }