dsh-autorestart 0.6.2 → 0.8.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.
Files changed (47) hide show
  1. package/README.md +460 -12
  2. package/assets/screenshot-1.png +0 -0
  3. package/assets/screenshot-2.png +0 -0
  4. package/assets/screenshot-3.png +0 -0
  5. package/assets/screenshot-4.png +0 -0
  6. package/assets/screenshot-5.png +0 -0
  7. package/client.js +6329 -3863
  8. package/cordis.patch.yml +44 -1
  9. package/docs/README.zh.md +297 -9
  10. package/docs/platform-matrix.md +92 -0
  11. package/docs/rescue-drill.md +606 -0
  12. package/index.js +2867 -199
  13. package/lib/access.js +212 -0
  14. package/lib/autostart-advice.js +198 -0
  15. package/lib/backup.js +131 -4
  16. package/lib/blockers.js +228 -0
  17. package/lib/checkpoint.js +147 -0
  18. package/lib/diagnostics.js +188 -0
  19. package/lib/health.js +264 -0
  20. package/lib/host-kind.js +145 -0
  21. package/lib/node-runtime.js +144 -0
  22. package/lib/rescue-paths.js +253 -0
  23. package/lib/rescue.js +596 -0
  24. package/lib/restart-verdict.js +327 -0
  25. package/lib/restart.js +823 -56
  26. package/lib/scheduler-capability.js +300 -0
  27. package/lib/sleep.js +46 -49
  28. package/lib/startup-mode.js +491 -0
  29. package/lib/supervisor-config.js +735 -0
  30. package/lib/supervisor-install.js +557 -0
  31. package/lib/supervisor.js +1276 -0
  32. package/lib/watchdog-arbiter.js +117 -0
  33. package/lib/watchdog-schedule.js +310 -0
  34. package/lib/watchdog-tier.js +182 -0
  35. package/lib/watchdog.js +479 -0
  36. package/locale/en.json +274 -64
  37. package/locale/zh.json +274 -64
  38. package/package.json +7 -2
  39. package/screenshots.json +6 -4
  40. package/scripts/README.md +65 -0
  41. package/scripts/drill-rescue.ps1 +508 -0
  42. package/scripts/emergency-disable.cmd +27 -0
  43. package/scripts/emergency-toggle.ps1 +373 -0
  44. package/scripts/restore-profile.cmd +44 -0
  45. package/scripts/restore-profile.ps1 +608 -0
  46. package/scripts/run-tests.ps1 +46 -0
  47. package/supervisor/supervisor.js +884 -0
@@ -0,0 +1,735 @@
1
+ /**
2
+ * dsh-autorestart — E (the derivation of a start command) and B (the dry run).
3
+ *
4
+ * E answers ONE question: **can this host be started again by a command we
5
+ * write down?** It must never guess. A wrong command is worse than no
6
+ * command: it looks like a restart and leaves the user with a dead host.
7
+ *
8
+ * The derivation is built from what the process itself knows:
9
+ *
10
+ * - `process.execPath` → the interpreter (node in the hosted mode, the
11
+ * desktop exe in the desktop app);
12
+ * - `process.execArgv` → the arguments of the interpreter itself;
13
+ * - `process.argv` → `[execPath, script, ...args]`; `argv.slice(1)` is
14
+ * the command line, replayable AS IT IS;
15
+ * - `process.cwd()` → the working directory.
16
+ *
17
+ * → the candidate is `[execPath, ...execArgv, ...argv.slice(1)]`.
18
+ *
19
+ * The PRIORITY (from the design):
20
+ *
21
+ * - **E0** our own supervisor started this instance → no card at all: the
22
+ * restart is asked of the supervisor over its control channel;
23
+ * - **E1** an EXTERNAL supervisor is in charge (systemd, a user script,
24
+ * pm2) → refuse to install a second one (they would fight over the
25
+ * port) and write "exit gracefully, the supervisor starts me again";
26
+ * - **E2** no supervisor and no init (a container) → do not promise an
27
+ * automatic start: give the command and the orchestrator fragment;
28
+ * - **E3** no supervisor, an init is available → the normal flow.
29
+ *
30
+ * The REFUSALS (each one has a code, the client translates it):
31
+ *
32
+ * - `desktop-app` → the desktop app brings itself back up;
33
+ * - `not-a-web-host` → a CLI/TUI host has no port and no watcher;
34
+ * - `argv-unavailable` → the command line cannot be read back;
35
+ * - `external-supervisor`→ somebody else is already the supervisor;
36
+ * - `container-no-init` → there is no "at login" on this machine;
37
+ * - `port-not-fixable` → the port changes on every start, so the cookie
38
+ * of the browser would die with it.
39
+ *
40
+ * ESM only, Node builtins only.
41
+ */
42
+
43
+ import fs from 'node:fs'
44
+ import net from 'node:net'
45
+ import path from 'node:path'
46
+
47
+ import { detachCommand as detachStartupCommand, STARTUP_MODES } from './startup-mode.js'
48
+
49
+ /** The reason codes of the derivation (the client translates them). */
50
+ export const DERIVE_REASONS = Object.freeze({
51
+ OK: 'ok',
52
+ DESKTOP_APP: 'desktop-app',
53
+ NOT_A_WEB_HOST: 'not-a-web-host',
54
+ ARGV_UNAVAILABLE: 'argv-unavailable',
55
+ EXTERNAL_SUPERVISOR: 'external-supervisor',
56
+ CONTAINER_NO_INIT: 'container-no-init',
57
+ PORT_NOT_FIXABLE: 'port-not-fixable',
58
+ SUPERVISED: 'supervised',
59
+ UNKNOWN_STARTUP: 'unknown-startup',
60
+ })
61
+
62
+ /** The modes the derivation can end in. */
63
+ export const DERIVE_MODES = Object.freeze({
64
+ /** The plugin's own supervisor is in charge. */
65
+ SUPERVISED: 'supervised',
66
+ /** An external supervisor is in charge: exit gracefully and let it start us. */
67
+ GRACEFUL_EXIT: 'graceful-exit',
68
+ /** A replayable command was derived. */
69
+ REPLAY: 'replay',
70
+ /** Nothing could be derived. */
71
+ REFUSED: 'refused',
72
+ })
73
+
74
+ /**
75
+ * The environment markers of the common supervisors.
76
+ *
77
+ * `INVOCATION_ID` is set by systemd for every unit it starts; `pm_id` and
78
+ * `PM2_HOME` are pm2's. They are cheap, exact and need no process tree.
79
+ */
80
+ const SUPERVISOR_ENV_MARKERS = Object.freeze([
81
+ { key: 'INVOCATION_ID', kind: 'systemd' },
82
+ { key: 'pm_id', kind: 'pm2' },
83
+ { key: 'PM2_HOME', kind: 'pm2' },
84
+ ])
85
+
86
+ /**
87
+ * The command lines of the common supervisors.
88
+ *
89
+ * The user's own script (`supervise-web.sh`) is matched by the shape of the
90
+ * name: a script that supervises something is a supervisor.
91
+ */
92
+ const SUPERVISOR_CMD_PATTERNS = Object.freeze([
93
+ { pattern: /systemd/i, kind: 'systemd' },
94
+ { pattern: /pm2/i, kind: 'pm2' },
95
+ { pattern: /supervisord/i, kind: 'runit' },
96
+ { pattern: /supervise[-_][\w.-]*\.(sh|bash|py|js)/i, kind: 'script' },
97
+ { pattern: /run[-_]?web[-_]?supervisor/i, kind: 'script' },
98
+ // The other common "something else starts me" shapes. The word boundary
99
+ // keeps them from matching a path that merely contains the word.
100
+ { pattern: /\brunsv\b/, kind: 'runit' },
101
+ { pattern: /\bs6-supervise\b/, kind: 'runit' },
102
+ { pattern: /\bforever\b/, kind: 'script' },
103
+ { pattern: /\bmonit\b/, kind: 'script' },
104
+ ])
105
+
106
+ /** How far up the parent chain the detection looks. */
107
+ export const SUPERVISOR_CHAIN_DEPTH = 4
108
+
109
+ /**
110
+ * Quote one argument for a shell command line.
111
+ *
112
+ * @param {string} value
113
+ * @returns {string}
114
+ */
115
+ export function quoteArgument(value) {
116
+ const text = String(value ?? '')
117
+ if (text === '') return '""'
118
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(text)) return text
119
+ return '"' + text.replace(/"/g, '\\"') + '"'
120
+ }
121
+
122
+ /**
123
+ * The command line of an argument list.
124
+ *
125
+ * @param {string[]} argv
126
+ * @returns {string}
127
+ */
128
+ export function formatCommand(argv) {
129
+ if (!Array.isArray(argv)) return ''
130
+ return argv.map((part) => quoteArgument(part)).join(' ')
131
+ }
132
+
133
+ /**
134
+ * Read the command line of a process (Linux: `/proc/<pid>/cmdline`).
135
+ *
136
+ * @param {number} pid
137
+ * @param {{ readFileSync?: Function }} [options]
138
+ * @returns {string} the command line, or an empty string
139
+ */
140
+ export function readProcessCommandLine(pid, options = {}) {
141
+ const read = typeof options.readFileSync === 'function' ? options.readFileSync : fs.readFileSync
142
+ const number = Number(pid)
143
+ if (!Number.isFinite(number) || number <= 0) return ''
144
+ try {
145
+ const raw = read(`/proc/${number}/cmdline`)
146
+ const text = Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw)
147
+ return text.split('\0').filter((part) => part !== '').join(' ')
148
+ } catch {
149
+ return ''
150
+ }
151
+ }
152
+
153
+ /**
154
+ * The parent of a process (Linux: `/proc/<pid>/status`).
155
+ *
156
+ * @param {number} pid
157
+ * @param {{ readFileSync?: Function }} [options]
158
+ * @returns {number} the parent pid, or 0 when it cannot be read
159
+ */
160
+ export function readProcessParentId(pid, options = {}) {
161
+ const read = typeof options.readFileSync === 'function' ? options.readFileSync : fs.readFileSync
162
+ const number = Number(pid)
163
+ if (!Number.isFinite(number) || number <= 0) return 0
164
+ try {
165
+ const raw = read(`/proc/${number}/status`)
166
+ const text = Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw)
167
+ const match = /^PPid:\s*(\d+)/m.exec(text)
168
+ return match ? Number(match[1]) : 0
169
+ } catch {
170
+ return 0
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Inspect the parent chain of this host: is somebody ELSE the supervisor?
176
+ *
177
+ * Unlike {@link detectExternalSupervisor} this function ALWAYS answers — with
178
+ * a reason when nothing was found. "Nothing is known" is a state of its own:
179
+ * it must never be reported as "a bare host", because a bare host is a host
180
+ * the plugin may restart by replaying its own command — and a host that is
181
+ * really supervised by somebody else must NOT be replayed (that is how two
182
+ * hosts end up fighting over the same port).
183
+ *
184
+ * The chain is walked upwards, not only the direct parent: a host started by
185
+ * `sh -c "supervise-web.sh"` has a shell as its direct parent and the script
186
+ * one level above.
187
+ *
188
+ * @param {{
189
+ * ppid?: number,
190
+ * env?: object,
191
+ * platform?: string,
192
+ * readFileSync?: Function,
193
+ * depth?: number,
194
+ * }} [options]
195
+ * @returns {{
196
+ * kind: string|null,
197
+ * evidence: string,
198
+ * commandLine: string,
199
+ * reason: string,
200
+ * checked: Array<{ pid: number, commandLine: string, matched: boolean }>,
201
+ * }}
202
+ */
203
+ export function inspectExternalSupervisor(options = {}) {
204
+ const env = options.env && typeof options.env === 'object' ? options.env : process.env
205
+ const platform = typeof options.platform === 'string' ? options.platform : process.platform
206
+ const depth = Number.isFinite(Number(options.depth)) && Number(options.depth) >= 0
207
+ ? Math.trunc(Number(options.depth))
208
+ : SUPERVISOR_CHAIN_DEPTH
209
+
210
+ for (const marker of SUPERVISOR_ENV_MARKERS) {
211
+ const value = env[marker.key]
212
+ if (typeof value === 'string' && value !== '') {
213
+ return {
214
+ kind: marker.kind,
215
+ evidence: `env:${marker.key}`,
216
+ commandLine: '',
217
+ reason: `the environment marker ${marker.key} says who started this host`,
218
+ checked: [],
219
+ }
220
+ }
221
+ }
222
+
223
+ if (platform === 'win32') {
224
+ return {
225
+ kind: null,
226
+ evidence: '',
227
+ commandLine: '',
228
+ reason: 'this platform does not expose a process tree; who starts this host cannot be read',
229
+ checked: [],
230
+ }
231
+ }
232
+
233
+ const start = Number.isFinite(Number(options.ppid)) ? Number(options.ppid) : process.ppid
234
+ const checked = []
235
+ let pid = Number(start)
236
+ const seen = new Set()
237
+ for (let level = 0; level <= depth && pid > 0 && !seen.has(pid); level += 1) {
238
+ seen.add(pid)
239
+ const commandLine = readProcessCommandLine(pid, options)
240
+ if (commandLine === '') {
241
+ if (checked.length === 0) {
242
+ return {
243
+ kind: null,
244
+ evidence: '',
245
+ commandLine: '',
246
+ reason: 'the command line of the parent process cannot be read',
247
+ checked,
248
+ }
249
+ }
250
+ break
251
+ }
252
+ let matched = null
253
+ for (const entry of SUPERVISOR_CMD_PATTERNS) {
254
+ if (entry.pattern.test(commandLine)) {
255
+ matched = entry.kind
256
+ break
257
+ }
258
+ }
259
+ checked.push({ pid, commandLine, matched: matched !== null })
260
+ if (matched !== null) {
261
+ return {
262
+ kind: matched,
263
+ evidence: level === 0 ? `parent:${pid}` : `ancestor:${pid}`,
264
+ commandLine,
265
+ reason: `the ${level === 0 ? 'parent' : 'ancestor'} process ${pid} is a supervisor`,
266
+ checked,
267
+ }
268
+ }
269
+ pid = readProcessParentId(pid, options)
270
+ }
271
+
272
+ return {
273
+ kind: null,
274
+ evidence: '',
275
+ commandLine: '',
276
+ reason: checked.length === 0
277
+ ? 'the parent process cannot be read'
278
+ : 'no known supervisor was found in the parent chain',
279
+ checked,
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Is somebody ELSE already supervising this host?
285
+ *
286
+ * The answer decides two things: whether a SECOND supervisor may be installed
287
+ * (it may not — two of them fight over the port), and what the restart
288
+ * command has to be (a graceful exit, because the other supervisor starts us
289
+ * again).
290
+ *
291
+ * @param {{
292
+ * ppid?: number,
293
+ * env?: object,
294
+ * platform?: string,
295
+ * readFileSync?: Function,
296
+ * selfMarker?: string,
297
+ * }} [options]
298
+ * @returns {{ kind: string, evidence: string, commandLine: string }|null}
299
+ */
300
+ export function detectExternalSupervisor(options = {}) {
301
+ const found = inspectExternalSupervisor(options)
302
+ if (found.kind === null) return null
303
+ return { kind: found.kind, evidence: found.evidence, commandLine: found.commandLine }
304
+ }
305
+
306
+ /**
307
+ * Is this a container (so there is no "at login")?
308
+ *
309
+ * @param {{ existsSync?: Function, env?: object }} [options]
310
+ * @returns {{ container: boolean, evidence: string }}
311
+ */
312
+ export function detectContainer(options = {}) {
313
+ const exists = typeof options.existsSyncFn === 'function' ? options.existsSyncFn : fs.existsSync
314
+ const env = options.env && typeof options.env === 'object' ? options.env : process.env
315
+ if (env.container === '1' || env.KUBERNETES_SERVICE_HOST) {
316
+ return { container: true, evidence: 'env' }
317
+ }
318
+ if (exists('/.dockerenv')) return { container: true, evidence: '/.dockerenv' }
319
+ if (exists('/run/.containerenv')) return { container: true, evidence: '/run/.containerenv' }
320
+ return { container: false, evidence: '' }
321
+ }
322
+
323
+ /**
324
+ * The `--port` of a command line, if it has one.
325
+ *
326
+ * @param {string[]} argv
327
+ * @returns {{ present: boolean, value: number|null }}
328
+ */
329
+ export function readPortArgument(argv) {
330
+ if (!Array.isArray(argv)) return { present: false, value: null }
331
+ for (let index = 0; index < argv.length; index += 1) {
332
+ const part = String(argv[index] ?? '')
333
+ if (part === '--port') {
334
+ const value = Number(argv[index + 1])
335
+ return { present: true, value: Number.isFinite(value) ? value : null }
336
+ }
337
+ if (part.startsWith('--port=')) {
338
+ const value = Number(part.slice('--port='.length))
339
+ return { present: true, value: Number.isFinite(value) ? value : null }
340
+ }
341
+ }
342
+ return { present: false, value: null }
343
+ }
344
+
345
+ /**
346
+ * The command that stops this host GRACEFULLY.
347
+ *
348
+ * It is what an EXTERNAL supervisor needs: the host goes away, the supervisor
349
+ * starts it again. It must NOT be a self-replay command — that would start a
350
+ * SECOND host next to the one the supervisor starts.
351
+ *
352
+ * @param {{ pid?: number, platform?: string }} [options]
353
+ * @returns {string}
354
+ */
355
+ export function gracefulExitCommand(options = {}) {
356
+ const pid = Number.isFinite(Number(options.pid)) ? Number(options.pid) : process.pid
357
+ const platform = typeof options.platform === 'string' ? options.platform : process.platform
358
+ if (platform === 'win32') return `taskkill /PID ${pid}`
359
+ return `kill -TERM ${pid}`
360
+ }
361
+
362
+ /**
363
+ * Derive the command that starts this host again — or say why there is none.
364
+ *
365
+ * @param {{
366
+ * execPath?: string,
367
+ * execArgv?: string[],
368
+ * argv?: string[],
369
+ * cwd?: string,
370
+ * platform?: string,
371
+ * env?: object,
372
+ * isDesktopApp?: boolean,
373
+ * webPort?: number,
374
+ * supervised?: boolean,
375
+ * externalSupervisor?: object|null,
376
+ * container?: boolean,
377
+ * hasInit?: boolean,
378
+ * fixedPort?: number,
379
+ * currentRestartMode?: string,
380
+ * currentRestartCommand?: string,
381
+ * healthPath?: string,
382
+ * }} [options]
383
+ * @returns {{
384
+ * ok: boolean,
385
+ * level: 'E0'|'E1'|'E2'|'E3',
386
+ * mode: string,
387
+ * reason: string,
388
+ * command: string|null,
389
+ * argv: string[]|null,
390
+ * cwd: string|null,
391
+ * port: number|null,
392
+ * portFixed: boolean,
393
+ * healthUrl: string|null,
394
+ * needsConfirmation: boolean,
395
+ * writes: { restartMode: string, restartCommand: string }|null,
396
+ * plan: Array<{ label: string, value: string }>,
397
+ * alternatives: string[],
398
+ * }}
399
+ */
400
+ export function deriveStartCommand(options = {}) {
401
+ const platform = typeof options.platform === 'string' ? options.platform : process.platform
402
+ const env = options.env && typeof options.env === 'object' ? options.env : process.env
403
+ const execPath = typeof options.execPath === 'string' ? options.execPath : process.execPath
404
+ const execArgv = Array.isArray(options.execArgv) ? options.execArgv.map(String) : []
405
+ const argv = Array.isArray(options.argv) ? options.argv.map(String) : []
406
+ const cwd = typeof options.cwd === 'string' ? options.cwd : process.cwd()
407
+ const isDesktopApp = options.isDesktopApp === true
408
+ const webPort = Number(options.webPort) > 0 ? Number(options.webPort) : 0
409
+ const supervised = options.supervised === true
410
+ let external = options.externalSupervisor && typeof options.externalSupervisor === 'object'
411
+ ? options.externalSupervisor
412
+ : null
413
+ let container = options.container === true
414
+ // THE STARTUP MATRIX: it knows HOW this host was started, which decides
415
+ // whether a self-replay is possible at all.
416
+ const startup = options.startup && typeof options.startup === 'object' ? options.startup : null
417
+ const hasInit = options.hasInit !== false
418
+ const fixedPort = Number(options.fixedPort) > 0 ? Number(options.fixedPort) : 0
419
+ const healthPath = typeof options.healthPath === 'string' && options.healthPath !== ''
420
+ ? options.healthPath
421
+ : '/__dsh_autorestart/health'
422
+
423
+ const base = {
424
+ ok: false,
425
+ level: 'E3',
426
+ mode: DERIVE_MODES.REFUSED,
427
+ reason: DERIVE_REASONS.OK,
428
+ command: null,
429
+ argv: null,
430
+ cwd: null,
431
+ port: null,
432
+ portFixed: false,
433
+ healthUrl: null,
434
+ needsConfirmation: true,
435
+ writes: null,
436
+ plan: [],
437
+ alternatives: [],
438
+ }
439
+
440
+ // E0: OUR supervisor started this instance. There is nothing to derive: the
441
+ // restart is asked of the supervisor over its control channel.
442
+ if (supervised) {
443
+ return {
444
+ ...base,
445
+ ok: true,
446
+ level: 'E0',
447
+ mode: DERIVE_MODES.SUPERVISED,
448
+ reason: DERIVE_REASONS.SUPERVISED,
449
+ needsConfirmation: false,
450
+ port: webPort || null,
451
+ healthUrl: webPort > 0 ? `http://127.0.0.1:${webPort}${healthPath}` : null,
452
+ plan: [
453
+ { label: 'supervisor', value: 'the supervisor of this plugin (its control channel)' },
454
+ { label: 'port', value: webPort > 0 ? String(webPort) : 'unknown' },
455
+ ],
456
+ }
457
+ }
458
+
459
+ // The desktop app brings ITSELF back up (restartMode: app): there is no
460
+ // command to derive.
461
+ if (isDesktopApp) {
462
+ return {
463
+ ...base,
464
+ level: 'E3',
465
+ mode: DERIVE_MODES.REFUSED,
466
+ reason: DERIVE_REASONS.DESKTOP_APP,
467
+ alternatives: ['app-mode'],
468
+ }
469
+ }
470
+
471
+ // A host without a web server has no port and no watcher: the plugin must
472
+ // not even ask.
473
+ if (webPort <= 0) {
474
+ return {
475
+ ...base,
476
+ level: 'E3',
477
+ mode: DERIVE_MODES.REFUSED,
478
+ reason: DERIVE_REASONS.NOT_A_WEB_HOST,
479
+ }
480
+ }
481
+
482
+ // The startup matrix decides what may be done at all:
483
+ //
484
+ // - an UNKNOWN starter: nothing is written (the plugin would rather say
485
+ // "I cannot tell" than start a second host);
486
+ // - an EXTERNAL starter: the same answer as a known external supervisor
487
+ // (exit gracefully, IT starts us again);
488
+ // - a CONTAINER: the orchestrator brings it back, and no supervisor is
489
+ // installed inside a container.
490
+ if (startup !== null) {
491
+ if (startup.mode === STARTUP_MODES.UNKNOWN) {
492
+ return {
493
+ ...base,
494
+ level: 'E3',
495
+ mode: DERIVE_MODES.REFUSED,
496
+ reason: DERIVE_REASONS.UNKNOWN_STARTUP,
497
+ alternatives: [],
498
+ plan: [
499
+ { label: 'startup', value: String(startup.evidence ?? '') },
500
+ { label: 'hint', value: String(startup.suggestion ?? '') },
501
+ ],
502
+ }
503
+ }
504
+ if (startup.mode === STARTUP_MODES.EXTERNAL && external === null) {
505
+ external = {
506
+ kind: String(startup.kind ?? 'external'),
507
+ evidence: String(startup.evidence ?? ''),
508
+ commandLine: '',
509
+ }
510
+ }
511
+ if (startup.mode === STARTUP_MODES.CONTAINER) container = true
512
+ }
513
+
514
+ // E1: somebody else is already the supervisor. A second one would fight
515
+ // over the port — so it is refused, and the restart becomes "exit
516
+ // gracefully, the other supervisor starts us again".
517
+ if (external !== null) {
518
+ const command = gracefulExitCommand({ pid: options.pid, platform })
519
+ return {
520
+ ...base,
521
+ ok: true,
522
+ level: 'E1',
523
+ mode: DERIVE_MODES.GRACEFUL_EXIT,
524
+ reason: DERIVE_REASONS.EXTERNAL_SUPERVISOR,
525
+ command,
526
+ argv: platform === 'win32' ? ['taskkill', '/PID', String(options.pid ?? process.pid)] : ['kill', '-TERM', String(options.pid ?? process.pid)],
527
+ cwd,
528
+ port: webPort,
529
+ healthUrl: `http://127.0.0.1:${webPort}${healthPath}`,
530
+ writes: { restartMode: 'command', restartCommand: command },
531
+ plan: [
532
+ { label: 'supervisor', value: `${external.kind} (${external.evidence})` },
533
+ { label: 'command', value: command },
534
+ { label: 'port', value: String(webPort) },
535
+ { label: 'health', value: `http://127.0.0.1:${webPort}${healthPath}` },
536
+ ],
537
+ alternatives: [],
538
+ }
539
+ }
540
+
541
+ // The command line has to be readable back: without `argv[1]` the host was
542
+ // started by a wrapper that erased it, and a replay would start the WRONG
543
+ // thing.
544
+ if (argv.length < 2 || String(argv[1] ?? '').trim() === '') {
545
+ return {
546
+ ...base,
547
+ level: 'E3',
548
+ mode: DERIVE_MODES.REFUSED,
549
+ reason: DERIVE_REASONS.ARGV_UNAVAILABLE,
550
+ alternatives: ['install-supervisor'],
551
+ }
552
+ }
553
+
554
+ // The port has to be FIXED: a random port changes the authority of the
555
+ // cookie, so the browser is kicked out after every restart.
556
+ const portArgument = readPortArgument(argv.slice(1))
557
+ let port = webPort
558
+ let portFixed = false
559
+ let replayArgv = [execPath, ...execArgv, ...argv.slice(1)]
560
+ if (!portArgument.present || portArgument.value === null || portArgument.value === 0) {
561
+ if (fixedPort <= 0) {
562
+ // Without a fixed port there is no way to keep the browser logged in.
563
+ return {
564
+ ...base,
565
+ level: 'E3',
566
+ mode: DERIVE_MODES.REFUSED,
567
+ reason: DERIVE_REASONS.PORT_NOT_FIXABLE,
568
+ alternatives: ['install-supervisor'],
569
+ }
570
+ }
571
+ port = fixedPort
572
+ portFixed = true
573
+ replayArgv = [...replayArgv, '--port', String(fixedPort)]
574
+ } else {
575
+ port = portArgument.value
576
+ }
577
+
578
+ // A host that was started detached (nohup/setsid) or inside a session
579
+ // (tmux/screen/ssh) needs a command that OUTLIVES the shell that runs it:
580
+ // the new host must not die with the session.
581
+ const mustDetach = startup !== null && startup.detachCommand === true
582
+ const plainCommand = formatCommand(replayArgv)
583
+ const command = mustDetach ? detachStartupCommand(plainCommand, platform) : plainCommand
584
+
585
+ // E2: a container has no "at login": the plugin does not promise an
586
+ // automatic start, it hands out the command and the orchestrator fragment.
587
+ // The command is NOT written down: the orchestrator brings the host back,
588
+ // and a self-replay command would start a second host.
589
+ if (container || !hasInit) {
590
+ const inContainer = startup !== null && startup.mode === STARTUP_MODES.CONTAINER
591
+ return {
592
+ ...base,
593
+ ok: true,
594
+ level: 'E2',
595
+ mode: DERIVE_MODES.REPLAY,
596
+ reason: DERIVE_REASONS.CONTAINER_NO_INIT,
597
+ command,
598
+ argv: replayArgv,
599
+ cwd,
600
+ port,
601
+ portFixed,
602
+ healthUrl: `http://127.0.0.1:${port}${healthPath}`,
603
+ writes: inContainer ? null : { restartMode: 'command', restartCommand: command },
604
+ plan: [
605
+ { label: 'command', value: command },
606
+ { label: 'cwd', value: cwd },
607
+ { label: 'port', value: String(port) },
608
+ { label: 'health', value: `http://127.0.0.1:${port}${healthPath}` },
609
+ ],
610
+ alternatives: inContainer
611
+ ? ['orchestrator-fragment']
612
+ : ['install-supervisor', 'orchestrator-fragment'],
613
+ }
614
+ }
615
+
616
+ // E3: the normal flow. The command is written down only after the user
617
+ // confirmed it (the dry run shows it first).
618
+ return {
619
+ ...base,
620
+ ok: true,
621
+ level: 'E3',
622
+ mode: DERIVE_MODES.REPLAY,
623
+ reason: DERIVE_REASONS.OK,
624
+ command,
625
+ argv: replayArgv,
626
+ cwd,
627
+ port,
628
+ portFixed,
629
+ healthUrl: `http://127.0.0.1:${port}${healthPath}`,
630
+ writes: { restartMode: 'command', restartCommand: command },
631
+ plan: [
632
+ { label: 'command', value: command },
633
+ ...(mustDetach ? [{ label: 'detached', value: 'the command outlives the shell that runs it' }] : []),
634
+ { label: 'cwd', value: cwd },
635
+ { label: 'port', value: String(port) },
636
+ { label: 'health', value: `http://127.0.0.1:${port}${healthPath}` },
637
+ ],
638
+ alternatives: ['install-supervisor'],
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Pick a free port.
644
+ *
645
+ * It is used when the host listens on a random port: the port has to be
646
+ * FIXED, or the authority of the browser's cookie changes with every start
647
+ * and the user is logged out after every restart.
648
+ *
649
+ * @returns {Promise<number>} the port (0 when nothing was free)
650
+ */
651
+ export function pickFreePort() {
652
+ return new Promise((resolve) => {
653
+ let settled = false
654
+ const done = (port) => {
655
+ if (settled) return
656
+ settled = true
657
+ resolve(port)
658
+ }
659
+ const server = net.createServer()
660
+ server.on('error', () => done(0))
661
+ server.listen(0, '127.0.0.1', () => {
662
+ const address = server.address()
663
+ const port = address && typeof address === 'object' ? Number(address.port) : 0
664
+ try {
665
+ server.close(() => done(port))
666
+ } catch {
667
+ done(port)
668
+ }
669
+ })
670
+ })
671
+ }
672
+
673
+ /**
674
+ * The dry run: everything the user has to see BEFORE anything is written.
675
+ *
676
+ * It answers "what would happen" — it never runs anything.
677
+ *
678
+ * @param {object} derivation the answer of {@link deriveStartCommand}
679
+ * @returns {{
680
+ * ok: boolean,
681
+ * level: string,
682
+ * mode: string,
683
+ * reason: string,
684
+ * plan: Array<{ label: string, value: string }>,
685
+ * command: string|null,
686
+ * writes: object|null,
687
+ * needsConfirmation: boolean,
688
+ * }}
689
+ */
690
+ export function buildDryRunPlan(derivation) {
691
+ const value = derivation && typeof derivation === 'object' ? derivation : {}
692
+ return {
693
+ ok: value.ok === true,
694
+ level: typeof value.level === 'string' ? value.level : 'E3',
695
+ mode: typeof value.mode === 'string' ? value.mode : DERIVE_MODES.REFUSED,
696
+ reason: typeof value.reason === 'string' ? value.reason : DERIVE_REASONS.OK,
697
+ plan: Array.isArray(value.plan) ? value.plan : [],
698
+ command: typeof value.command === 'string' ? value.command : null,
699
+ writes: value.writes && typeof value.writes === 'object' ? value.writes : null,
700
+ needsConfirmation: value.needsConfirmation !== false,
701
+ }
702
+ }
703
+
704
+ /**
705
+ * The compose fragment for a machine without a service manager.
706
+ *
707
+ * The command is an ARGUMENT LIST, not a string: a path with a space in it
708
+ * must not be cut in half.
709
+ *
710
+ * @param {string[]|string} argv
711
+ * @returns {string}
712
+ */
713
+ export function composeRestartFragment(argv) {
714
+ const list = Array.isArray(argv)
715
+ ? argv.map((part) => String(part))
716
+ : String(argv ?? '').split(' ').filter((part) => part !== '')
717
+ return [
718
+ 'services:',
719
+ ' dsh:',
720
+ ' image: <your dsh image>',
721
+ ' command: ' + JSON.stringify(list),
722
+ ' restart: always',
723
+ ].join('\n')
724
+ }
725
+
726
+ /**
727
+ * The path of the supervisor script inside the package (for the E0 control
728
+ * command).
729
+ *
730
+ * @param {string} stateDir
731
+ * @returns {string}
732
+ */
733
+ export function supervisorScriptPath(stateDir) {
734
+ return path.join(String(stateDir ?? ''), 'supervisor', 'supervisor.js')
735
+ }