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.
- package/README.md +329 -12
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/assets/screenshot-3.png +0 -0
- package/assets/screenshot-4.png +0 -0
- package/assets/screenshot-5.png +0 -0
- package/client.js +5911 -3832
- package/cordis.patch.yml +37 -1
- package/docs/README.zh.md +219 -9
- package/index.js +2266 -193
- package/lib/access.js +212 -0
- package/lib/autostart-advice.js +198 -0
- package/lib/backup.js +131 -4
- package/lib/blockers.js +228 -0
- package/lib/checkpoint.js +147 -0
- package/lib/diagnostics.js +180 -0
- package/lib/health.js +264 -0
- package/lib/host-kind.js +145 -0
- package/lib/node-runtime.js +144 -0
- package/lib/restart-verdict.js +327 -0
- package/lib/restart.js +823 -56
- package/lib/sleep.js +46 -49
- package/lib/startup-mode.js +491 -0
- package/lib/supervisor-config.js +735 -0
- package/lib/supervisor-install.js +557 -0
- package/lib/supervisor.js +1276 -0
- package/locale/en.json +247 -64
- package/locale/zh.json +247 -64
- package/package.json +7 -2
- package/screenshots.json +6 -4
- package/scripts/emergency-disable.cmd +27 -0
- package/scripts/emergency-toggle.ps1 +302 -0
- package/scripts/run-tests.ps1 +46 -0
- 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
|
+
}
|