dsh-autorestart 0.6.2 → 0.7.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.
@@ -0,0 +1,1276 @@
1
+ /**
2
+ * dsh-autorestart — the plugin side of the supervisor.
3
+ *
4
+ * The plugin does four things with the supervisor: INSTALL it, UNINSTALL it,
5
+ * ASK about its state, and ASK it to restart the host. Everything else (the
6
+ * daemon itself) lives in `supervisor/supervisor.js` and runs OUTSIDE the
7
+ * host — the plugin cannot be the thing that brings the host back, because
8
+ * the plugin dies with the host.
9
+ *
10
+ * THE THREE RULES OF THIS FILE:
11
+ *
12
+ * 1. **The installation is idempotent.** Installing twice leaves exactly ONE
13
+ * record (the task name / the unit name / the startup file name are
14
+ * fixed, and the scheduled task is created with `/F`). A second
15
+ * supervisor is impossible: the daemon takes an exclusive lock.
16
+ * 2. **The installation is reversible.** The previous `restartMode` /
17
+ * `restartCommand` are saved before anything is changed, and the
18
+ * uninstall puts them back. When a step of the uninstall fails, the
19
+ * pending cleanup is WRITTEN DOWN (never silently dropped) so the user
20
+ * can finish it by hand.
21
+ * 3. **Nothing is installed without the user's nod.** The caller (the
22
+ * confirmation card) has to pass `confirmed: true`; without it the
23
+ * installation only produces a PLAN (the dry run).
24
+ *
25
+ * ESM only, Node builtins only. No DSH / npm imports.
26
+ */
27
+
28
+ import fs from 'node:fs'
29
+ import net from 'node:net'
30
+ import os from 'node:os'
31
+ import path from 'node:path'
32
+ import { execFileSync } from 'node:child_process'
33
+
34
+ import {
35
+ buildInstallSteps,
36
+ buildUninstallSteps,
37
+ composeFragment,
38
+ detectInitSystems,
39
+ runCommand,
40
+ stepCommandLine,
41
+ supervisorCommandLine,
42
+ SUPERVISOR_TASK_NAME,
43
+ windowsStartupCmdContent,
44
+ windowsStartupCmdPath,
45
+ } from './supervisor-install.js'
46
+ import { inspectExternalSupervisor } from './supervisor-config.js'
47
+ import { isNodeExecutable, resolveNodeInterpreter } from './node-runtime.js'
48
+ import { classifyStartup, STARTUP_MODES } from './startup-mode.js'
49
+ import { planAutostartAdvice } from './autostart-advice.js'
50
+
51
+ /** The name of the directory the supervisor lives in (inside the state dir). */
52
+ export const SUPERVISOR_DIR_NAME = 'supervisor'
53
+
54
+ /** The name of the file that says "the uninstall did not finish". */
55
+ export const UNINSTALL_PENDING_NAME = 'UNINSTALL-PENDING.json'
56
+
57
+ /** The version of the supervisor configuration this plugin writes. */
58
+ export const SUPERVISOR_CONFIG_VERSION = 1
59
+
60
+ /** The environment marker that says "a supervisor started me". */
61
+ export const SUPERVISED_ENV = 'DSH_AUTORESTART_SUPERVISED'
62
+
63
+ /** The environment marker that carries the supervisor's pid. */
64
+ export const SUPERVISOR_PID_ENV = 'DSH_AUTORESTART_SUPERVISOR_PID'
65
+
66
+ /**
67
+ * The error code of a host that manages ITSELF (the desktop app).
68
+ *
69
+ * The desktop app brings itself back up (`restartMode: app`). A login record
70
+ * that starts the supervisor would start a SECOND client at every login: the
71
+ * record names the application executable, and the application executable
72
+ * opens the whole client again. So the desktop host refuses the installation
73
+ * — and it refuses it before a single step is generated.
74
+ */
75
+ export const DESKTOP_SUPERVISOR_ERROR = 'desktop-manages-itself'
76
+
77
+ /** The reason that goes with {@link DESKTOP_SUPERVISOR_ERROR}. */
78
+ export const DESKTOP_SUPERVISOR_REASON =
79
+ 'the desktop app relaunches itself; a supervisor would start a second copy'
80
+
81
+ /** The error code of a host without a usable node interpreter. */
82
+ export const NODE_INTERPRETER_ERROR = 'node-interpreter-not-found'
83
+
84
+ /**
85
+ * The error code of a host whose startup mode forbids a second supervisor.
86
+ *
87
+ * Only a `bare` host (nobody manages it) may get the supervisor of this
88
+ * plugin: an external supervisor, a container or an unknown starter would end
89
+ * up with two hosts fighting over the same port.
90
+ */
91
+ export const STARTUP_NOT_INSTALLABLE = 'startup-not-installable'
92
+
93
+ /**
94
+ * The error code of a WSL instance without systemd.
95
+ *
96
+ * There is no "at login" inside such a distribution: the plugin hands out
97
+ * the two ways out (turn systemd on inside WSL, or let Windows start the
98
+ * distribution) and installs nothing.
99
+ */
100
+ export const WSL_NO_SYSTEMD = 'wsl-no-systemd'
101
+
102
+ /** The reason the desktop app reports for its own restart capability. */
103
+ export const DESKTOP_RESTART_REASON = 'the desktop app relaunches itself (restartMode: app)'
104
+
105
+ /** @param {unknown} value */
106
+ function errorText(value) {
107
+ if (value && typeof value.message === 'string' && value.message) return value.message
108
+ return String(value)
109
+ }
110
+
111
+ /**
112
+ * Every path the supervisor uses inside the state directory.
113
+ *
114
+ * @param {string} stateDir
115
+ * @returns {{
116
+ * dir: string, script: string, config: string, lock: string, pid: string,
117
+ * control: string, log: string, rollbackRequest: string, pendingUninstall: string,
118
+ * }}
119
+ */
120
+ export function supervisorPaths(stateDir) {
121
+ const dir = path.join(String(stateDir ?? ''), SUPERVISOR_DIR_NAME)
122
+ return {
123
+ dir,
124
+ script: path.join(dir, 'supervisor.js'),
125
+ config: path.join(dir, 'supervisor.json'),
126
+ lock: path.join(dir, 'supervisor.lock'),
127
+ pid: path.join(dir, 'supervisor.pid'),
128
+ control: path.join(dir, 'control.json'),
129
+ log: path.join(dir, 'supervisor.log'),
130
+ rollbackRequest: path.join(dir, 'rollback-request.json'),
131
+ // The to-do list of a cleanup that did not finish. It lives NEXT TO the
132
+ // supervisor directory, not inside it: the cleanup removes that directory,
133
+ // and a list inside it would either be deleted with it or bring the
134
+ // directory back to life (which is what happened before).
135
+ pendingUninstall: path.join(String(stateDir ?? ''), UNINSTALL_PENDING_NAME),
136
+ }
137
+ }
138
+
139
+ /**
140
+ * The path of the supervisor script inside THIS plugin package.
141
+ *
142
+ * @returns {string}
143
+ */
144
+ export function packagedSupervisorPath() {
145
+ return new URL('../supervisor/supervisor.js', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')
146
+ }
147
+
148
+ /**
149
+ * The configuration of the supervisor.
150
+ *
151
+ * @param {{
152
+ * execPath?: string, execArgv?: string[], argv?: string[], cwd?: string,
153
+ * env?: object, profileEnv?: object, port?: number, healthUrl?: string,
154
+ * profileDir?: string, stateDir?: string, ownerUser?: string,
155
+ * timings?: object, previous?: object, mode?: string, generation?: number,
156
+ * }} [options]
157
+ * @returns {object}
158
+ */
159
+ export function buildSupervisorConfig(options = {}) {
160
+ return {
161
+ version: SUPERVISOR_CONFIG_VERSION,
162
+ installedAt: new Date().toISOString(),
163
+ ownerUser: typeof options.ownerUser === 'string' ? options.ownerUser : '',
164
+ execPath: typeof options.execPath === 'string' ? options.execPath : '',
165
+ execArgv: Array.isArray(options.execArgv) ? options.execArgv.map(String) : [],
166
+ argv: Array.isArray(options.argv) ? options.argv.map(String) : [],
167
+ cwd: typeof options.cwd === 'string' ? options.cwd : '',
168
+ env: options.env && typeof options.env === 'object' ? options.env : {},
169
+ profileEnv: options.profileEnv && typeof options.profileEnv === 'object' ? options.profileEnv : {},
170
+ port: Number.isFinite(Number(options.port)) ? Number(options.port) : 0,
171
+ healthUrl: typeof options.healthUrl === 'string' ? options.healthUrl : '',
172
+ profileDir: typeof options.profileDir === 'string' ? options.profileDir : '',
173
+ stateDir: typeof options.stateDir === 'string' ? options.stateDir : '',
174
+ control: { type: 'tcp', host: '127.0.0.1', port: 0 },
175
+ timings: {
176
+ restartDelayMs: 1000,
177
+ restartDelayMaxMs: 30000,
178
+ startTimeoutMs: 90000,
179
+ killGraceMs: 15000,
180
+ portFreeTimeoutMs: 30000,
181
+ ...(options.timings && typeof options.timings === 'object' ? options.timings : {}),
182
+ },
183
+ previous: options.previous && typeof options.previous === 'object' ? options.previous : null,
184
+ mode: options.mode === 'external' ? 'external' : 'installed',
185
+ // Which form was actually registered (it can differ from the plan when the
186
+ // scheduled task was refused and the startup folder took over).
187
+ kind: typeof options.kind === 'string' && options.kind !== '' ? options.kind : null,
188
+ generation: Number.isFinite(Number(options.generation)) ? Number(options.generation) : 1,
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Write the supervisor configuration.
194
+ *
195
+ * @param {string} stateDir
196
+ * @param {object} config
197
+ * @returns {{ ok: boolean, path: string, error: string|null }}
198
+ */
199
+ export function writeSupervisorConfig(stateDir, config) {
200
+ const paths = supervisorPaths(stateDir)
201
+ try {
202
+ fs.mkdirSync(paths.dir, { recursive: true })
203
+ const temporary = paths.config + '.tmp'
204
+ fs.writeFileSync(temporary, JSON.stringify(config, null, 2) + '\n', 'utf8')
205
+ fs.renameSync(temporary, paths.config)
206
+ return { ok: true, path: paths.config, error: null }
207
+ } catch (error) {
208
+ return { ok: false, path: paths.config, error: errorText(error) }
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Read the supervisor configuration.
214
+ *
215
+ * @param {string} stateDir
216
+ * @returns {{ ok: boolean, config: object|null, error: string|null }}
217
+ */
218
+ export function readSupervisorConfig(stateDir) {
219
+ const paths = supervisorPaths(stateDir)
220
+ try {
221
+ const parsed = JSON.parse(fs.readFileSync(paths.config, 'utf8'))
222
+ if (!parsed || typeof parsed !== 'object') return { ok: false, config: null, error: 'the configuration is not an object' }
223
+ return { ok: true, config: parsed, error: null }
224
+ } catch (error) {
225
+ return { ok: false, config: null, error: errorText(error) }
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Write the rollback request the daemon reads when the host does not come back.
231
+ *
232
+ * @param {string} stateDir
233
+ * @param {{ backupDir: string, profileDir: string, generation?: number, reason?: string }} request
234
+ * @returns {{ ok: boolean, path: string, error: string|null }}
235
+ */
236
+ export function writeRollbackRequest(stateDir, request) {
237
+ const paths = supervisorPaths(stateDir)
238
+ try {
239
+ fs.mkdirSync(paths.dir, { recursive: true })
240
+ fs.writeFileSync(paths.rollbackRequest, JSON.stringify({
241
+ backupDir: String(request && request.backupDir ? request.backupDir : ''),
242
+ profileDir: String(request && request.profileDir ? request.profileDir : ''),
243
+ generation: Number.isFinite(Number(request && request.generation)) ? Number(request.generation) : 0,
244
+ reason: String(request && request.reason ? request.reason : 'plugin-restart'),
245
+ requestedAt: new Date().toISOString(),
246
+ }, null, 2) + '\n', 'utf8')
247
+ return { ok: true, path: paths.rollbackRequest, error: null }
248
+ } catch (error) {
249
+ return { ok: false, path: paths.rollbackRequest, error: errorText(error) }
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Read the rollback request.
255
+ *
256
+ * @param {string} stateDir
257
+ * @returns {object|null}
258
+ */
259
+ export function readRollbackRequest(stateDir) {
260
+ const paths = supervisorPaths(stateDir)
261
+ try {
262
+ const parsed = JSON.parse(fs.readFileSync(paths.rollbackRequest, 'utf8'))
263
+ return parsed && typeof parsed === 'object' ? parsed : null
264
+ } catch {
265
+ return null
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Is this instance the one the supervisor started?
271
+ *
272
+ * The ENVIRONMENT MARKER is the authoritative evidence: the daemon injects it
273
+ * into the host it starts. The pid file is the second hand (it also carries
274
+ * the address of the control channel); the process tree is NOT used — a
275
+ * daemon that `exec`s the host, or a daemon that died first, leaves the host
276
+ * re-parented to pid 1, so the parent chain proves nothing.
277
+ *
278
+ * @param {{ env?: object, stateDir?: string }} [options]
279
+ * @returns {{ supervised: boolean, reason: string, control: object|null, pid: number|null }}
280
+ */
281
+ export function detectSupervisor(options = {}) {
282
+ const env = options.env && typeof options.env === 'object' ? options.env : process.env
283
+ const stateDir = typeof options.stateDir === 'string' ? options.stateDir : ''
284
+ const marker = env[SUPERVISED_ENV]
285
+ const markerPid = Number(env[SUPERVISOR_PID_ENV])
286
+ let control = null
287
+ let pid = Number.isFinite(markerPid) && markerPid > 0 ? markerPid : null
288
+ if (stateDir !== '') {
289
+ const paths = supervisorPaths(stateDir)
290
+ try {
291
+ control = JSON.parse(fs.readFileSync(paths.control, 'utf8'))
292
+ } catch {
293
+ control = null
294
+ }
295
+ try {
296
+ const written = Number(fs.readFileSync(paths.pid, 'utf8').trim())
297
+ if (Number.isFinite(written) && written > 0) pid = written
298
+ } catch {
299
+ /* the pid file is optional */
300
+ }
301
+ }
302
+ if (marker === '1' || marker === 'true') {
303
+ return { supervised: true, reason: 'the supervisor started this instance', control, pid }
304
+ }
305
+ if (control !== null && pid !== null) {
306
+ return {
307
+ supervised: false,
308
+ reason: 'a supervisor is installed, but this instance was not started by it',
309
+ control,
310
+ pid,
311
+ }
312
+ }
313
+ return { supervised: false, reason: 'this instance was not started by a supervisor', control, pid }
314
+ }
315
+
316
+ /**
317
+ * Is a process alive?
318
+ *
319
+ * @param {number} pid
320
+ * @returns {boolean}
321
+ */
322
+ function isProcessAlive(pid) {
323
+ const number = Number(pid)
324
+ if (!Number.isFinite(number) || number <= 0) return false
325
+ try {
326
+ process.kill(number, 0)
327
+ return true
328
+ } catch (error) {
329
+ return error && error.code === 'EPERM'
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Ask the supervisor to do something over its control channel.
335
+ *
336
+ * @param {{ stateDir?: string, control?: object, action?: string, timeoutMs?: number }} options
337
+ * @returns {Promise<{ ok: boolean, response: object|null, error: string|null }>}
338
+ */
339
+ export function requestSupervisorAction(options = {}) {
340
+ const action = typeof options.action === 'string' && options.action !== '' ? options.action : 'restart'
341
+ let control = options.control && typeof options.control === 'object' ? options.control : null
342
+ if (control === null && typeof options.stateDir === 'string' && options.stateDir !== '') {
343
+ try {
344
+ control = JSON.parse(fs.readFileSync(supervisorPaths(options.stateDir).control, 'utf8'))
345
+ } catch {
346
+ control = null
347
+ }
348
+ }
349
+ if (!control || control.type !== 'tcp' || !Number.isFinite(Number(control.port)) || Number(control.port) <= 0) {
350
+ return Promise.resolve({ ok: false, response: null, error: 'the supervisor has no control channel' })
351
+ }
352
+ const timeout = Number.isFinite(Number(options.timeoutMs)) && Number(options.timeoutMs) > 0
353
+ ? Math.trunc(Number(options.timeoutMs))
354
+ : 5000
355
+ return new Promise((resolve) => {
356
+ let settled = false
357
+ const done = (result) => {
358
+ if (settled) return
359
+ settled = true
360
+ resolve(result)
361
+ }
362
+ const socket = net.connect({ port: Number(control.port), host: String(control.host || '127.0.0.1') })
363
+ let buffer = ''
364
+ const timer = setTimeout(() => {
365
+ try {
366
+ socket.destroy()
367
+ } catch {
368
+ /* nothing else to do */
369
+ }
370
+ done({ ok: false, response: null, error: 'the supervisor did not answer in time' })
371
+ }, timeout)
372
+ socket.on('connect', () => {
373
+ try {
374
+ socket.write(JSON.stringify({ action }) + '\n')
375
+ } catch {
376
+ /* the timeout below reports it */
377
+ }
378
+ })
379
+ socket.on('data', (chunk) => {
380
+ buffer += String(chunk)
381
+ const index = buffer.indexOf('\n')
382
+ if (index < 0) return
383
+ clearTimeout(timer)
384
+ let response = null
385
+ try {
386
+ response = JSON.parse(buffer.slice(0, index))
387
+ } catch {
388
+ response = null
389
+ }
390
+ try {
391
+ socket.destroy()
392
+ } catch {
393
+ /* nothing else to do */
394
+ }
395
+ done({ ok: Boolean(response && response.ok === true), response, error: null })
396
+ })
397
+ socket.on('error', (error) => {
398
+ clearTimeout(timer)
399
+ done({ ok: false, response: null, error: errorText(error) })
400
+ })
401
+ })
402
+ }
403
+
404
+ /**
405
+ * The node interpreter the supervisor is started with.
406
+ *
407
+ * A name that IS a node interpreter is taken as it is (the caller knows what
408
+ * it wants); anything else — the Electron client of the desktop app, a
409
+ * wrapper — is looked up on the machine. When nothing is found the caller
410
+ * refuses the installation: the application executable is never used as a
411
+ * stand-in for node.
412
+ *
413
+ * @param {{
414
+ * nodePath?: string,
415
+ * execPath?: string,
416
+ * platform?: string,
417
+ * env?: object,
418
+ * existsSyncFn?: Function,
419
+ * }} [options]
420
+ * @returns {{ ok: boolean, path: string, source: string, reason: string }}
421
+ */
422
+ export function resolveSupervisorNode(options = {}) {
423
+ const given = String(options.nodePath ?? '')
424
+ if (isNodeExecutable(given)) {
425
+ return { ok: true, path: given, source: 'given', reason: '' }
426
+ }
427
+ return resolveNodeInterpreter({
428
+ execPath: typeof options.execPath === 'string' && options.execPath !== '' ? options.execPath : process.execPath,
429
+ platform: options.platform,
430
+ env: options.env,
431
+ existsSyncFn: options.existsSyncFn,
432
+ })
433
+ }
434
+
435
+ /**
436
+ * Write down what a cleanup did not finish.
437
+ *
438
+ * The list lives NEXT TO the supervisor directory, so writing it cannot bring
439
+ * that directory back to life. It is written ONLY when there is really
440
+ * something left — a list that says "nothing is left" is a lie the user
441
+ * would act on.
442
+ *
443
+ * @param {string} stateDir
444
+ * @param {{ failed: Array<object>, manual: string[], reason?: string }} payload
445
+ * @returns {{ ok: boolean, path: string, pending: object|null, error: string|null }}
446
+ */
447
+ export function writePendingCleanup(stateDir, payload) {
448
+ const paths = supervisorPaths(stateDir)
449
+ const pending = {
450
+ createdAt: new Date().toISOString(),
451
+ reason: typeof payload.reason === 'string' ? payload.reason : 'cleanup-incomplete',
452
+ failed: Array.isArray(payload.failed) ? payload.failed : [],
453
+ manual: Array.isArray(payload.manual) ? payload.manual.map(String) : [],
454
+ directory: paths.dir,
455
+ }
456
+ try {
457
+ fs.mkdirSync(path.dirname(paths.pendingUninstall), { recursive: true })
458
+ fs.writeFileSync(paths.pendingUninstall, JSON.stringify(pending, null, 2) + '\n', 'utf8')
459
+ return { ok: true, path: paths.pendingUninstall, pending, error: null }
460
+ } catch (error) {
461
+ return { ok: false, path: paths.pendingUninstall, pending, error: errorText(error) }
462
+ }
463
+ }
464
+
465
+ /**
466
+ * The commands that finish a cleanup by hand.
467
+ *
468
+ * @param {Array<{ file?: string, args?: string[], path?: string }>} steps
469
+ * @returns {string[]}
470
+ */
471
+ export function manualCleanupCommands(steps) {
472
+ if (!Array.isArray(steps)) return []
473
+ return steps.map((step) => stepCommandLine(step)).filter((line) => line !== '')
474
+ }
475
+
476
+ /**
477
+ * The plan of an installation (what WOULD be changed).
478
+ *
479
+ * @param {{
480
+ * platform?: string, nodePath?: string, supervisorPath?: string, configPath?: string,
481
+ * homeDir?: string, startupDir?: string, user?: string, kind?: string,
482
+ * isDesktopApp?: boolean, execPath?: string,
483
+ * startup?: object, host?: { kind?: string, suspect?: boolean, distro?: string, evidence?: string },
484
+ * }} options
485
+ * @returns {{
486
+ * kind: string,
487
+ * steps: Array<object>,
488
+ * degraded: boolean,
489
+ * compose: string|null,
490
+ * nodePath: string,
491
+ * error: string|null,
492
+ * reason: string,
493
+ * notes?: string[],
494
+ * commands?: string[],
495
+ * options?: Array<object>,
496
+ * }}
497
+ */
498
+ export function planSupervisorInstall(options = {}) {
499
+ const platform = typeof options.platform === 'string' ? options.platform : process.platform
500
+
501
+ // (1) THE DESKTOP APP BRINGS ITSELF BACK UP. A login record that starts
502
+ // the supervisor would open a SECOND client at every login (it names the
503
+ // application executable, and that executable opens the whole client). So
504
+ // the plan of the desktop host is EMPTY and says why.
505
+ if (options.isDesktopApp === true) {
506
+ return {
507
+ kind: 'none',
508
+ steps: [],
509
+ degraded: false,
510
+ compose: null,
511
+ nodePath: '',
512
+ error: DESKTOP_SUPERVISOR_ERROR,
513
+ reason: DESKTOP_SUPERVISOR_REASON,
514
+ }
515
+ }
516
+
517
+ // (2) THE STARTUP MODE. Only a `bare` host (nobody manages it) may get the
518
+ // supervisor of this plugin: an external supervisor, a container or an
519
+ // unknown starter would end up with TWO hosts fighting over the same port.
520
+ const startup = options.startup && typeof options.startup === 'object' ? options.startup : null
521
+ if (startup !== null && startup.canInstallSupervisor !== true) {
522
+ return {
523
+ kind: 'none',
524
+ steps: [],
525
+ degraded: false,
526
+ compose: null,
527
+ nodePath: '',
528
+ error: STARTUP_NOT_INSTALLABLE,
529
+ reason: typeof startup.suggestion === 'string' && startup.suggestion !== ''
530
+ ? startup.suggestion
531
+ : 'the startup mode "' + String(startup.mode) + '" does not allow a supervisor of this plugin',
532
+ }
533
+ }
534
+
535
+ // (3) THE INTERPRETER MUST BE A REAL NODE. `process.execPath` is the
536
+ // Electron client in the desktop app; a login record that names it as the
537
+ // interpreter starts the whole client again. The interpreter is resolved,
538
+ // and when there is none the installation is refused with the reason.
539
+ const interpreter = resolveSupervisorNode({
540
+ nodePath: options.nodePath,
541
+ execPath: options.execPath,
542
+ platform,
543
+ env: options.env,
544
+ existsSyncFn: options.existsSyncFn,
545
+ })
546
+ if (!interpreter.ok) {
547
+ return {
548
+ kind: 'none',
549
+ steps: [],
550
+ degraded: false,
551
+ compose: null,
552
+ nodePath: '',
553
+ error: NODE_INTERPRETER_ERROR,
554
+ reason: interpreter.reason,
555
+ }
556
+ }
557
+ const nodePath = interpreter.path
558
+
559
+ const detected = detectInitSystems({
560
+ platform,
561
+ homeDir: options.homeDir,
562
+ env: options.env,
563
+ existsSyncFn: options.existsSyncFn,
564
+ execFileSyncFn: options.execFileSyncFn,
565
+ })
566
+ const steps = buildInstallSteps({
567
+ platform,
568
+ kind: options.kind,
569
+ nodePath,
570
+ supervisorPath: options.supervisorPath,
571
+ configPath: options.configPath,
572
+ homeDir: options.homeDir,
573
+ startupDir: options.startupDir,
574
+ user: options.user,
575
+ })
576
+ // (4) THE PLATFORM GUIDE. Every hosted platform gets ITS OWN advice:
577
+ // Windows (a scheduled task, with the startup folder as the fallback),
578
+ // Linux (a systemd user unit + the linger hint), WSL (a user unit + the
579
+ // linger, or the two ways out when there is no systemd at all), a container
580
+ // (the orchestrator) and the unknown host (nothing, and no guessing).
581
+ const host = options.host && typeof options.host === 'object' ? options.host : null
582
+ const advice = planAutostartAdvice({
583
+ host,
584
+ platform,
585
+ hasSystemd: detected.systemdUser,
586
+ container: options.container === true,
587
+ user: options.user,
588
+ distro: host !== null ? host.distro : '',
589
+ command: supervisorCommandLine({
590
+ nodePath,
591
+ supervisorPath: options.supervisorPath,
592
+ configPath: options.configPath,
593
+ }),
594
+ })
595
+
596
+ if (host !== null && host.kind === 'wsl' && !detected.systemdUser) {
597
+ // No systemd in WSL: there is no "at login" to register. The two ways
598
+ // out are handed to the user — the plugin changes nothing, and it NEVER
599
+ // suggests Docker/Compose here (the orchestrator of WSL is Windows).
600
+ return {
601
+ kind: 'wsl-manual',
602
+ steps: [],
603
+ degraded: false,
604
+ compose: null,
605
+ nodePath,
606
+ error: WSL_NO_SYSTEMD,
607
+ reason: advice.reason,
608
+ notes: advice.notes,
609
+ commands: advice.commands,
610
+ options: advice.options,
611
+ }
612
+ }
613
+
614
+ if (platform !== 'win32' && !detected.systemdUser) {
615
+ return {
616
+ kind: 'none',
617
+ steps: [],
618
+ degraded: false,
619
+ compose: composeFragment({ ...options, nodePath }),
620
+ nodePath,
621
+ error: null,
622
+ reason: detected.reason,
623
+ notes: advice.notes,
624
+ commands: advice.commands,
625
+ options: advice.options,
626
+ }
627
+ }
628
+ return {
629
+ kind: platform === 'win32' ? 'windows-schtasks' : (options.kind === 'systemd-system' ? 'systemd-system' : 'systemd-user'),
630
+ steps,
631
+ degraded: false,
632
+ compose: null,
633
+ nodePath,
634
+ error: null,
635
+ reason: advice.reason,
636
+ notes: advice.notes,
637
+ commands: advice.commands,
638
+ options: advice.options,
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Install the supervisor.
644
+ *
645
+ * The order is fixed: the FILES first (the daemon, the configuration), then
646
+ * the autostart record. When the record cannot be registered, the files are
647
+ * removed again — a half-installed supervisor must not stay behind.
648
+ *
649
+ * @param {{
650
+ * stateDir?: string,
651
+ * profileDir?: string,
652
+ * nodePath?: string,
653
+ * execPath?: string,
654
+ * execArgv?: string[],
655
+ * argv?: string[],
656
+ * cwd?: string,
657
+ * env?: object,
658
+ * profileEnv?: object,
659
+ * port?: number,
660
+ * healthUrl?: string,
661
+ * ownerUser?: string,
662
+ * timings?: object,
663
+ * previous?: object|null,
664
+ * platform?: string,
665
+ * homeDir?: string,
666
+ * startupDir?: string,
667
+ * user?: string,
668
+ * kind?: string,
669
+ * isDesktopApp?: boolean,
670
+ * confirmed?: boolean,
671
+ * dryRun?: boolean,
672
+ * execFileSyncFn?: Function,
673
+ * existsSyncFn?: Function,
674
+ * copySupervisor?: Function,
675
+ * log?: Function,
676
+ * }} [options]
677
+ * @returns {{
678
+ * ok: boolean, kind: string, installed: boolean, degraded: boolean,
679
+ * steps: Array<object>, error: string|null, reason: string, config: object|null,
680
+ * compose: string|null, plan: object|null,
681
+ * }}
682
+ */
683
+ export function installSupervisor(options = {}) {
684
+ const log = typeof options.log === 'function' ? options.log : () => {}
685
+ const stateDir = typeof options.stateDir === 'string' ? options.stateDir : ''
686
+ const platform = typeof options.platform === 'string' ? options.platform : process.platform
687
+ const paths = supervisorPaths(stateDir)
688
+ const homeDir = typeof options.homeDir === 'string' && options.homeDir !== '' ? options.homeDir : os.homedir()
689
+ const startupDir = typeof options.startupDir === 'string'
690
+ ? options.startupDir
691
+ : (process.env.APPDATA
692
+ ? path.join(String(process.env.APPDATA), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
693
+ : '')
694
+ const exec = typeof options.execFileSyncFn === 'function' ? options.execFileSyncFn : execFileSync
695
+ const exists = typeof options.existsSyncFn === 'function' ? options.existsSyncFn : fs.existsSync
696
+ const requestedNodePath = typeof options.nodePath === 'string' && options.nodePath !== ''
697
+ ? options.nodePath
698
+ : (typeof options.execPath === 'string' && options.execPath !== '' ? options.execPath : process.execPath)
699
+
700
+ const plan = planSupervisorInstall({
701
+ platform,
702
+ nodePath: requestedNodePath,
703
+ execPath: typeof options.execPath === 'string' && options.execPath !== '' ? options.execPath : process.execPath,
704
+ isDesktopApp: options.isDesktopApp === true,
705
+ startup: options.startup,
706
+ // WHICH MACHINE IS THIS? WSL is not a Linux: the "start at login" there is
707
+ // a different thing (and the Docker/Compose answer is a wrong one).
708
+ host: options.host,
709
+ supervisorPath: paths.script,
710
+ configPath: paths.config,
711
+ homeDir,
712
+ startupDir,
713
+ user: options.user,
714
+ kind: options.kind,
715
+ env: options.env,
716
+ existsSyncFn: options.existsSyncFn,
717
+ execFileSyncFn: options.execFileSyncFn,
718
+ })
719
+
720
+ const base = {
721
+ ok: false,
722
+ kind: plan.kind,
723
+ installed: false,
724
+ degraded: false,
725
+ steps: [],
726
+ error: plan.error ?? null,
727
+ reason: plan.reason,
728
+ config: null,
729
+ compose: plan.compose,
730
+ // The advice of the WSL branch (the linger command, the two ways out).
731
+ notes: Array.isArray(plan.notes) ? plan.notes : [],
732
+ commands: Array.isArray(plan.commands) ? plan.commands : [],
733
+ options: Array.isArray(plan.options) ? plan.options : [],
734
+ plan,
735
+ }
736
+
737
+ if (stateDir === '') return { ...base, error: 'installSupervisor requires stateDir' }
738
+ // The plan refused: the desktop app manages itself, or there is no node
739
+ // interpreter to start the supervisor with. Nothing is written and no
740
+ // command is run — the refusal happens BEFORE the files are copied.
741
+ if (plan.error) {
742
+ return { ...base, error: plan.error, reason: plan.reason }
743
+ }
744
+ if (plan.kind === 'none') {
745
+ return {
746
+ ...base,
747
+ error: 'this machine has no "start at login" mechanism: ' + plan.reason,
748
+ }
749
+ }
750
+ // The interpreter the record really uses (a real node, never the app exe).
751
+ const nodePath = plan.nodePath
752
+
753
+ // The plan is what the confirmation card shows. Without the user's nod
754
+ // nothing is written at all.
755
+ if (options.dryRun === true || options.confirmed !== true) {
756
+ return {
757
+ ...base,
758
+ ok: options.dryRun === true,
759
+ steps: plan.steps,
760
+ reason: options.dryRun === true
761
+ ? 'dry run: nothing was written'
762
+ : 'the installation needs the confirmation card',
763
+ error: options.dryRun === true ? null : 'the installation needs the confirmation card',
764
+ }
765
+ }
766
+
767
+ // (1) The files.
768
+ try {
769
+ fs.mkdirSync(paths.dir, { recursive: true })
770
+ const source = typeof options.copySupervisor === 'function'
771
+ ? options.copySupervisor()
772
+ : fs.readFileSync(packagedSupervisorPath())
773
+ const temporary = paths.script + '.tmp'
774
+ fs.writeFileSync(temporary, source)
775
+ fs.renameSync(temporary, paths.script)
776
+ } catch (error) {
777
+ return { ...base, error: 'the supervisor script could not be copied: ' + errorText(error) }
778
+ }
779
+
780
+ const config = buildSupervisorConfig({
781
+ execPath: options.execPath,
782
+ execArgv: options.execArgv,
783
+ argv: options.argv,
784
+ cwd: options.cwd,
785
+ env: options.env,
786
+ profileEnv: options.profileEnv,
787
+ port: options.port,
788
+ healthUrl: options.healthUrl,
789
+ profileDir: options.profileDir,
790
+ stateDir,
791
+ ownerUser: options.ownerUser,
792
+ timings: options.timings,
793
+ previous: options.previous,
794
+ mode: 'installed',
795
+ })
796
+ const written = writeSupervisorConfig(stateDir, config)
797
+ if (!written.ok) {
798
+ return { ...base, error: 'the supervisor configuration could not be written: ' + written.error }
799
+ }
800
+
801
+ // (2) The autostart record.
802
+ const steps = []
803
+ let kind = plan.kind
804
+ let degraded = false
805
+ let error = null
806
+
807
+ if (platform === 'win32') {
808
+ const task = buildInstallSteps({
809
+ platform,
810
+ nodePath,
811
+ supervisorPath: paths.script,
812
+ configPath: paths.config,
813
+ startupDir,
814
+ })
815
+ const taskStep = task[0]
816
+ const fallbackStep = task[1]
817
+ const created = runCommand(taskStep.file, taskStep.args, { execFileSyncFn: exec })
818
+ steps.push({ kind: taskStep.kind, description: taskStep.description, ok: created.ok, error: created.error })
819
+ if (created.ok) {
820
+ // "It answered" is not "it is there": the record is verified.
821
+ const query = runCommand('schtasks', ['/Query', '/TN', SUPERVISOR_TASK_NAME], { execFileSyncFn: exec })
822
+ steps.push({ kind: 'verify', description: 'check that the scheduled task exists', ok: query.ok, error: query.error })
823
+ if (!query.ok) {
824
+ error = 'the scheduled task was created but cannot be queried: ' + String(query.error)
825
+ }
826
+ } else {
827
+ // The degradation: the startup folder needs no rights at all.
828
+ log('the scheduled task was refused; falling back to the startup folder: ' + String(created.error))
829
+ degraded = true
830
+ kind = 'windows-startup'
831
+ try {
832
+ fs.mkdirSync(path.dirname(fallbackStep.path), { recursive: true })
833
+ fs.writeFileSync(fallbackStep.path, fallbackStep.content, 'utf8')
834
+ steps.push({ kind: fallbackStep.kind, description: fallbackStep.description, ok: true, error: null })
835
+ } catch (fallbackError) {
836
+ error = 'the scheduled task was refused (' + String(created.error)
837
+ + ') and the startup folder could not be written: ' + errorText(fallbackError)
838
+ }
839
+ }
840
+ } else {
841
+ const unit = buildInstallSteps({
842
+ platform,
843
+ kind: options.kind,
844
+ nodePath,
845
+ supervisorPath: paths.script,
846
+ configPath: paths.config,
847
+ homeDir,
848
+ user: options.user,
849
+ })
850
+ for (const step of unit) {
851
+ if (step.kind === 'systemd-user' || step.kind === 'systemd-system') {
852
+ try {
853
+ fs.mkdirSync(path.dirname(step.path), { recursive: true })
854
+ fs.writeFileSync(step.path, step.content, 'utf8')
855
+ steps.push({ kind: step.kind, description: step.description, ok: true, error: null })
856
+ } catch (writeError) {
857
+ error = 'the unit file could not be written: ' + errorText(writeError)
858
+ steps.push({ kind: step.kind, description: step.description, ok: false, error: errorText(writeError) })
859
+ break
860
+ }
861
+ continue
862
+ }
863
+ const answer = runCommand(step.file, step.args, { execFileSyncFn: exec })
864
+ steps.push({ kind: step.kind, description: step.description, ok: answer.ok, error: answer.error })
865
+ if (!answer.ok) {
866
+ error = step.description + ' failed: ' + String(answer.error)
867
+ break
868
+ }
869
+ }
870
+ }
871
+
872
+ if (error !== null) {
873
+ // Nothing may stay half-installed: the record that was written is taken
874
+ // away again — and the report says what the rollback did. A failed
875
+ // installation that leaves an enabled unit behind would start a stale
876
+ // supervisor at the next login, so the rollback is not optional.
877
+ const rollback = buildUninstallSteps({ platform, kind, homeDir, startupDir })
878
+ for (const step of rollback) {
879
+ if (step.path) {
880
+ try {
881
+ fs.rmSync(step.path, { force: true })
882
+ steps.push({ kind: 'rollback-' + step.kind, description: step.description, ok: true, error: null })
883
+ } catch (rollbackError) {
884
+ steps.push({ kind: 'rollback-' + step.kind, description: step.description, ok: false, error: errorText(rollbackError) })
885
+ }
886
+ continue
887
+ }
888
+ if (!step.file) continue
889
+ const answer = runCommand(step.file, step.args, { execFileSyncFn: exec })
890
+ steps.push({ kind: 'rollback-' + step.kind, description: step.description, ok: answer.ok, error: answer.error })
891
+ }
892
+ try {
893
+ fs.rmSync(paths.dir, { recursive: true, force: true })
894
+ } catch {
895
+ /* the report says what is left */
896
+ }
897
+ const leftOver = steps
898
+ .filter((step) => String(step.kind).startsWith('rollback-') && step.ok === false)
899
+ if (leftOver.length > 0) {
900
+ // The rollback did not finish either: what is left is written down, so
901
+ // the user can finish it by hand instead of finding a stale unit later.
902
+ // (The step that failed the installation is NOT residue — the rollback
903
+ // took it away again.)
904
+ writePendingCleanup(stateDir, {
905
+ reason: 'install-rollback-incomplete',
906
+ failed: leftOver.map((step) => ({ kind: step.kind, description: step.description, error: step.error })),
907
+ manual: manualCleanupCommands(rollback),
908
+ })
909
+ }
910
+ return { ...base, kind, degraded, steps, error, config }
911
+ }
912
+
913
+ // The kind is known now (it may have changed through the degradation), so the
914
+ // configuration is written again with it.
915
+ config.kind = kind
916
+ writeSupervisorConfig(stateDir, config)
917
+
918
+ return {
919
+ ...base,
920
+ ok: true,
921
+ kind,
922
+ degraded,
923
+ installed: true,
924
+ steps,
925
+ error: null,
926
+ config,
927
+ reason: degraded
928
+ ? 'the scheduled task was not allowed; the startup folder is used instead'
929
+ : plan.reason,
930
+ }
931
+ }
932
+
933
+ /**
934
+ * Uninstall the supervisor and give the machine back to the user.
935
+ *
936
+ * @param {{
937
+ * stateDir?: string,
938
+ * platform?: string,
939
+ * homeDir?: string,
940
+ * startupDir?: string,
941
+ * dryRun?: boolean,
942
+ * execFileSyncFn?: Function,
943
+ * stopSupervisor?: Function,
944
+ * log?: Function,
945
+ * }} [options]
946
+ * @returns {Promise<{
947
+ * ok: boolean, steps: Array<object>, error: string|null,
948
+ * pending: object|null, previous: object|null,
949
+ * }>}
950
+ */
951
+ export async function uninstallSupervisor(options = {}) {
952
+ const log = typeof options.log === 'function' ? options.log : () => {}
953
+ const stateDir = typeof options.stateDir === 'string' ? options.stateDir : ''
954
+ const platform = typeof options.platform === 'string' ? options.platform : process.platform
955
+ const paths = supervisorPaths(stateDir)
956
+ const exec = typeof options.execFileSyncFn === 'function' ? options.execFileSyncFn : null
957
+ const homeDir = typeof options.homeDir === 'string' && options.homeDir !== '' ? options.homeDir : os.homedir()
958
+ const startupDir = typeof options.startupDir === 'string'
959
+ ? options.startupDir
960
+ : (process.env.APPDATA
961
+ ? path.join(String(process.env.APPDATA), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
962
+ : '')
963
+
964
+ const config = readSupervisorConfig(stateDir)
965
+ const kind = config.ok && config.config && typeof config.config.kind === 'string'
966
+ ? config.config.kind
967
+ : (platform === 'win32' ? 'windows-schtasks' : 'systemd-user')
968
+ const previous = config.ok && config.config && config.config.previous && typeof config.config.previous === 'object'
969
+ ? config.config.previous
970
+ : null
971
+
972
+ const steps = buildUninstallSteps({ platform, kind, homeDir, startupDir })
973
+ const base = {
974
+ ok: false,
975
+ steps: [],
976
+ error: null,
977
+ pending: null,
978
+ previous,
979
+ }
980
+ if (stateDir === '') return { ...base, error: 'uninstallSupervisor requires stateDir' }
981
+
982
+ if (options.dryRun === true) {
983
+ return { ...base, ok: true, steps, error: null, reason: 'dry run: nothing was changed' }
984
+ }
985
+
986
+ const done = []
987
+
988
+ // (1) Stop the supervisor. The control channel is the polite way; the pid
989
+ // is the fallback.
990
+ const stop = typeof options.stopSupervisor === 'function'
991
+ ? options.stopSupervisor
992
+ : (args) => requestSupervisorAction(args)
993
+ const stopped = await stop({ stateDir, action: 'stop' })
994
+ if (!stopped || stopped.ok !== true) {
995
+ log('the supervisor did not stop through its control channel: ' + String(stopped && stopped.error))
996
+ }
997
+ done.push({ kind: 'stop', description: 'stop the supervisor', ok: true, error: null })
998
+
999
+ // (2) The autostart record.
1000
+ for (const step of steps) {
1001
+ if (step.path) {
1002
+ try {
1003
+ fs.rmSync(step.path, { force: true })
1004
+ done.push({ kind: step.kind, description: step.description, ok: true, error: null })
1005
+ } catch (error) {
1006
+ done.push({ kind: step.kind, description: step.description, ok: false, error: errorText(error) })
1007
+ }
1008
+ continue
1009
+ }
1010
+ const answer = runCommand(step.file, step.args, { execFileSyncFn: exec })
1011
+ done.push({ kind: step.kind, description: step.description, ok: answer.ok, error: answer.error })
1012
+ }
1013
+
1014
+ // (3) The state directory of the supervisor. The report must match the
1015
+ // machine: the directory is really gone, or it is not.
1016
+ let cleanupError = null
1017
+ try {
1018
+ fs.rmSync(paths.dir, { recursive: true, force: true })
1019
+ } catch (error) {
1020
+ cleanupError = errorText(error)
1021
+ }
1022
+ const directoryGone = !fs.existsSync(paths.dir)
1023
+ done.push({
1024
+ kind: 'cleanup',
1025
+ description: 'remove the supervisor directory',
1026
+ ok: cleanupError === null && directoryGone,
1027
+ error: cleanupError === null && directoryGone
1028
+ ? null
1029
+ : (cleanupError ?? 'the supervisor directory is still there'),
1030
+ })
1031
+
1032
+ const failed = done.filter((step) => step.ok !== true)
1033
+ if (failed.length > 0) {
1034
+ // The uninstall did not finish: what is left is written down, with the
1035
+ // commands that finish it by hand. A half-removed record that still
1036
+ // starts a stale supervisor would be worse than a written-down list.
1037
+ // The list lives NEXT TO the supervisor directory, so writing it cannot
1038
+ // bring that directory back to life.
1039
+ const written = writePendingCleanup(stateDir, {
1040
+ reason: 'uninstall-incomplete',
1041
+ failed: failed.map((step) => ({ kind: step.kind, description: step.description, error: step.error })),
1042
+ manual: manualCleanupCommands(steps),
1043
+ })
1044
+ return {
1045
+ ...base,
1046
+ ok: false,
1047
+ steps: done,
1048
+ error: 'the uninstall did not finish; the pending cleanup was written to ' + written.path,
1049
+ pending: written.pending,
1050
+ }
1051
+ }
1052
+
1053
+ // Nothing is left: a to-do list from an earlier attempt would be a lie.
1054
+ try {
1055
+ fs.rmSync(paths.pendingUninstall, { force: true })
1056
+ } catch {
1057
+ /* nothing else to do */
1058
+ }
1059
+ return { ...base, ok: true, steps: done, error: null, pending: null }
1060
+ }
1061
+
1062
+ /**
1063
+ * The startup classification as the status reports it.
1064
+ *
1065
+ * It is the answer of the startup matrix: the mode, the EVIDENCE the verdict
1066
+ * rests on (so the user can see WHY the plugin says what it says), and what
1067
+ * the plugin may do about it.
1068
+ *
1069
+ * @param {object|null|undefined} startup
1070
+ * @returns {{
1071
+ * mode: string, kind: string, evidence: string, selfReplay: string,
1072
+ * canInstallSupervisor: boolean, detachCommand: boolean, suggestion: string,
1073
+ * }|null}
1074
+ */
1075
+ export function startupSummary(startup) {
1076
+ if (!startup || typeof startup !== 'object') return null
1077
+ return {
1078
+ mode: typeof startup.mode === 'string' ? startup.mode : STARTUP_MODES.UNKNOWN,
1079
+ kind: typeof startup.kind === 'string' ? startup.kind : '',
1080
+ evidence: typeof startup.evidence === 'string' ? startup.evidence : '',
1081
+ selfReplay: typeof startup.selfReplay === 'string' ? startup.selfReplay : 'unknown',
1082
+ canInstallSupervisor: startup.canInstallSupervisor === true,
1083
+ detachCommand: startup.detachCommand === true,
1084
+ suggestion: typeof startup.suggestion === 'string' ? startup.suggestion : '',
1085
+ }
1086
+ }
1087
+
1088
+ /**
1089
+ * What the plugin knows about the supervisor.
1090
+ *
1091
+ * THREE SOURCES, ONE ANSWER:
1092
+ *
1093
+ * 1. the plugin's OWN supervisor (the marker in the environment and the
1094
+ * configuration file it wrote);
1095
+ * 2. an EXTERNAL supervisor (systemd, pm2, a `supervise-*.sh` script of the
1096
+ * user) — the parent chain of this very process;
1097
+ * 3. the configured mode (`external` = "my own way", `off`).
1098
+ *
1099
+ * The second one used to be missing here: the status answered "nothing is
1100
+ * known about how this host is started" while the parent chain plainly said
1101
+ * `/bin/bash /home/fg/supervise-web.sh`. That is not a cosmetic gap — a host
1102
+ * that somebody else supervises must NOT be restarted by a self-replay (the
1103
+ * supervisor starts it again, and the replay would start a SECOND host on the
1104
+ * same port). So the external supervisor is detected here, reported with its
1105
+ * evidence, and the self-replay is refused.
1106
+ *
1107
+ * @param {{
1108
+ * stateDir?: string,
1109
+ * env?: object,
1110
+ * platform?: string,
1111
+ * mode?: string,
1112
+ * ppid?: number,
1113
+ * isDesktopApp?: boolean,
1114
+ * inspectExternal?: Function,
1115
+ * execFileSyncFn?: Function,
1116
+ * existsSyncFn?: Function,
1117
+ * }} [options]
1118
+ * @returns {{
1119
+ * mode: 'app'|'installed'|'external'|'unknown'|'off',
1120
+ * kind: string|null,
1121
+ * installed: boolean,
1122
+ * running: boolean,
1123
+ * supervised: boolean,
1124
+ * restartCapable: boolean,
1125
+ * reason: string,
1126
+ * external: { kind: string, evidence: string, commandLine: string }|null,
1127
+ * selfReplay: 'allowed'|'refused'|'unknown'|'n/a',
1128
+ * detail: object,
1129
+ * }}
1130
+ */
1131
+ export function supervisorStatus(options = {}) {
1132
+ const stateDir = typeof options.stateDir === 'string' ? options.stateDir : ''
1133
+ const platform = typeof options.platform === 'string' ? options.platform : process.platform
1134
+ // The desktop app brings ITSELF back up: it is not a host that waits for
1135
+ // somebody else, and it is never "unknown".
1136
+ const isDesktopApp = options.isDesktopApp === true
1137
+ const detected = detectSupervisor({ env: options.env, stateDir })
1138
+ const config = readSupervisorConfig(stateDir)
1139
+ const paths = supervisorPaths(stateDir)
1140
+ const installed = config.ok && config.config !== null
1141
+ const running = detected.pid !== null && isProcessAlive(detected.pid)
1142
+ const kind = installed && config.config && typeof config.config.kind === 'string' ? config.config.kind : null
1143
+ const configuredMode = typeof options.mode === 'string' ? options.mode : (installed && config.config ? config.config.mode : 'auto')
1144
+
1145
+ // WHO ELSE IS IN CHARGE? The parent chain of this very process is the
1146
+ // evidence (Linux: `/proc/<ppid>/cmdline`). The inspection never throws and
1147
+ // always answers — with a reason when it found nothing.
1148
+ let external = null
1149
+ let externalReason = ''
1150
+ try {
1151
+ const inspect = typeof options.inspectExternal === 'function'
1152
+ ? options.inspectExternal
1153
+ : inspectExternalSupervisor
1154
+ const found = inspect({
1155
+ env: options.env,
1156
+ platform,
1157
+ ppid: options.ppid,
1158
+ })
1159
+ externalReason = found && typeof found.reason === 'string' ? found.reason : ''
1160
+ if (found && typeof found.kind === 'string' && found.kind !== '') {
1161
+ external = {
1162
+ kind: found.kind,
1163
+ evidence: typeof found.evidence === 'string' ? found.evidence : '',
1164
+ commandLine: typeof found.commandLine === 'string' ? found.commandLine : '',
1165
+ }
1166
+ }
1167
+ } catch (error) {
1168
+ externalReason = 'the supervisor detection failed: ' + errorText(error)
1169
+ }
1170
+
1171
+ let mode = 'unknown'
1172
+ let reason = 'nothing is known about how this host is started'
1173
+ if (isDesktopApp) {
1174
+ // THE DESKTOP APP BRINGS ITSELF BACK UP (`restartMode: app`). It is NOT
1175
+ // "nobody knows": the app knows, and the restart works. Reporting
1176
+ // "unknown" here would tell the user that the restart is unavailable
1177
+ // while the app is restarting itself all the time — so the desktop host
1178
+ // answers first and never falls through to "unknown".
1179
+ mode = 'app'
1180
+ reason = DESKTOP_RESTART_REASON
1181
+ } else if (configuredMode === 'external') {
1182
+ // The user said so: their own way of starting this host.
1183
+ mode = 'external'
1184
+ reason = 'this host is started by your own way'
1185
+ } else if (installed) {
1186
+ mode = 'installed'
1187
+ reason = running
1188
+ ? 'the supervisor of this plugin is running'
1189
+ : 'the supervisor of this plugin is installed but not running'
1190
+ } else if (detected.supervised) {
1191
+ mode = 'installed'
1192
+ reason = 'this instance was started by a supervisor'
1193
+ } else if (external !== null) {
1194
+ // Somebody else is in charge. The plugin must not start a second copy.
1195
+ mode = 'external'
1196
+ reason = `this host is started by an external supervisor (${external.kind}, ${external.evidence})`
1197
+ } else if (configuredMode === 'off') {
1198
+ mode = 'off'
1199
+ reason = 'the automatic restart is switched off'
1200
+ } else if (externalReason !== '') {
1201
+ // "Nothing is known" is NOT "a bare host": the plugin says so instead of
1202
+ // pretending it may replay its own command.
1203
+ reason = 'it could not be established who starts this host: ' + externalReason
1204
+ }
1205
+
1206
+ // The restart can only work when SOMEBODY brings the host back: the
1207
+ // supervisor of this plugin, the one of the user — or the desktop app
1208
+ // itself, which relaunches its own process.
1209
+ const restartCapable = isDesktopApp
1210
+ || detected.supervised
1211
+ || mode === 'external'
1212
+ || (mode === 'installed' && running)
1213
+
1214
+ // May the plugin write a command that starts THIS host again? Only when it
1215
+ // is in charge itself. With an external supervisor the answer is a plain
1216
+ // "no" (the supervisor starts the host again) — and when nothing is known
1217
+ // the answer is "unknown", never a silent "yes".
1218
+ let selfReplay = 'unknown'
1219
+ if (mode === 'app') selfReplay = 'n/a'
1220
+ else if (mode === 'installed') selfReplay = 'allowed'
1221
+ else if (mode === 'external' || mode === 'off') selfReplay = 'refused'
1222
+
1223
+ // A conflict is a real double-start risk: the supervisor of this plugin is
1224
+ // registered AND somebody else is in charge. Both would start the host.
1225
+ const conflict = installed && external !== null
1226
+ const finalReason = conflict
1227
+ ? reason + ' — and an external supervisor (' + external.kind + ') is in charge as well: both would start this host, so one of them has to go'
1228
+ : reason
1229
+
1230
+ return {
1231
+ mode,
1232
+ kind,
1233
+ installed,
1234
+ running,
1235
+ supervised: detected.supervised,
1236
+ restartCapable,
1237
+ reason: finalReason,
1238
+ external,
1239
+ selfReplay,
1240
+ // HOW THIS HOST WAS STARTED (the startup matrix): the mode, the evidence
1241
+ // the verdict rests on, and what the plugin may do about it.
1242
+ startup: startupSummary(options.startup),
1243
+ // THE PLATFORM GUIDE: how "start at login" works HERE (Windows / Linux /
1244
+ // WSL / container), with the commands the user can copy.
1245
+ autostart: options.autostart && typeof options.autostart === 'object'
1246
+ ? options.autostart
1247
+ : null,
1248
+ detail: {
1249
+ detected: detected.reason,
1250
+ pid: detected.pid,
1251
+ control: detected.control,
1252
+ configPath: paths.config,
1253
+ // The evidence of the external supervisor (and WHY nothing was found).
1254
+ externalReason,
1255
+ externalChecked: external !== null ? [external.evidence] : [],
1256
+ conflict,
1257
+ pendingUninstall: (() => {
1258
+ try {
1259
+ return fs.existsSync(paths.pendingUninstall)
1260
+ } catch {
1261
+ return false
1262
+ }
1263
+ })(),
1264
+ // What the user has to run by hand when the uninstall did not finish.
1265
+ pendingCommands: (() => {
1266
+ try {
1267
+ const raw = fs.readFileSync(paths.pendingUninstall, 'utf8')
1268
+ const parsed = JSON.parse(raw)
1269
+ return parsed && Array.isArray(parsed.manual) ? parsed.manual.map(String) : []
1270
+ } catch {
1271
+ return []
1272
+ }
1273
+ })(),
1274
+ },
1275
+ }
1276
+ }