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
package/lib/sleep.js
CHANGED
|
@@ -30,11 +30,17 @@
|
|
|
30
30
|
import { spawn as spawnProcess } from 'node:child_process';
|
|
31
31
|
import fs from 'node:fs';
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
import { blockerTaskKeys, countBlockerTasks } from './blockers.js';
|
|
34
|
+
import { detectHostKind, detectWslEvidence, HOST_KINDS, WSL_PROC_VERSION, WSL_SHUTDOWN_EXE } from './host-kind.js';
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
36
|
+
/**
|
|
37
|
+
* The Windows shutdown binary as it is reachable from WSL.
|
|
38
|
+
*
|
|
39
|
+
* The detection itself lives in `host-kind.js` (the shutdown and the autostart
|
|
40
|
+
* halves of the plugin must agree on what machine this is); it is re-exported
|
|
41
|
+
* here so the old import path keeps working.
|
|
42
|
+
*/
|
|
43
|
+
export { WSL_SHUTDOWN_EXE, WSL_PROC_VERSION, detectWslEvidence };
|
|
38
44
|
|
|
39
45
|
/** How many seconds the countdown lasts by default. */
|
|
40
46
|
export const DEFAULT_COUNTDOWN_SECONDS = 20;
|
|
@@ -119,41 +125,6 @@ export function snapCountdownChoice(value) {
|
|
|
119
125
|
return best;
|
|
120
126
|
}
|
|
121
127
|
|
|
122
|
-
/**
|
|
123
|
-
* The evidence that this Linux is really a WSL distribution.
|
|
124
|
-
*
|
|
125
|
-
* Any ONE of them is enough:
|
|
126
|
-
* 1. `WSL_DISTRO_NAME` is set (WSL exports it into every process),
|
|
127
|
-
* 2. `WSL_INTEROP` is set (WSL exports it into every process),
|
|
128
|
-
* 3. `/proc/version` mentions "microsoft" (the WSL kernel says so),
|
|
129
|
-
* 4. the Windows shutdown binary is reachable under `/mnt/c`.
|
|
130
|
-
*
|
|
131
|
-
* @param {object} env
|
|
132
|
-
* @param {(file: string) => string} readFile
|
|
133
|
-
* @param {(file: string) => boolean} exists
|
|
134
|
-
* @returns {string|null} the name of the evidence, or null
|
|
135
|
-
*/
|
|
136
|
-
export function detectWslEvidence(env, readFile, exists) {
|
|
137
|
-
const environ = env && typeof env === 'object' ? env : {};
|
|
138
|
-
if (nonEmptyString(environ.WSL_DISTRO_NAME) !== '') return 'WSL_DISTRO_NAME';
|
|
139
|
-
if (nonEmptyString(environ.WSL_INTEROP) !== '') return 'WSL_INTEROP';
|
|
140
|
-
let version = '';
|
|
141
|
-
try {
|
|
142
|
-
version = String(readFile(WSL_PROC_VERSION) || '');
|
|
143
|
-
} catch {
|
|
144
|
-
version = '';
|
|
145
|
-
}
|
|
146
|
-
if (version.toLowerCase().includes('microsoft')) return WSL_PROC_VERSION;
|
|
147
|
-
let found = false;
|
|
148
|
-
try {
|
|
149
|
-
found = exists(WSL_SHUTDOWN_EXE) === true;
|
|
150
|
-
} catch {
|
|
151
|
-
found = false;
|
|
152
|
-
}
|
|
153
|
-
if (found) return WSL_SHUTDOWN_EXE;
|
|
154
|
-
return null;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
128
|
/**
|
|
158
129
|
* Detect the host and build the command that switches the physical machine off.
|
|
159
130
|
*
|
|
@@ -203,12 +174,12 @@ export function detectShutdownPlan({
|
|
|
203
174
|
}
|
|
204
175
|
|
|
205
176
|
if (plat === 'linux') {
|
|
206
|
-
const
|
|
207
|
-
if (
|
|
177
|
+
const host = detectHostKind({ platform: plat, env, readFile: readFileSafe, exists: existsSafe });
|
|
178
|
+
if (host.kind === HOST_KINDS.WSL) {
|
|
208
179
|
// The mounted Windows binary is only a HINT: a real Linux with a
|
|
209
180
|
// manually mounted C: drive looks exactly the same. When that is the
|
|
210
181
|
// only evidence, the plan says so instead of pretending to be sure.
|
|
211
|
-
const suspect =
|
|
182
|
+
const suspect = host.suspect === true;
|
|
212
183
|
return {
|
|
213
184
|
kind: 'wsl',
|
|
214
185
|
label: suspect
|
|
@@ -216,7 +187,7 @@ export function detectShutdownPlan({
|
|
|
216
187
|
: 'WSL 里的 DSH → 关掉整台 Windows',
|
|
217
188
|
source: suspect
|
|
218
189
|
? `${WSL_SHUTDOWN_EXE}(疑似 WSL,仅凭 /mnt/c 探测)`
|
|
219
|
-
: evidence,
|
|
190
|
+
: host.evidence,
|
|
220
191
|
// The Windows binary, reached through the mounted C: drive: this shuts
|
|
221
192
|
// the whole machine down, not the WSL distribution.
|
|
222
193
|
command: WSL_SHUTDOWN_EXE,
|
|
@@ -550,9 +521,14 @@ export function createSleepController({
|
|
|
550
521
|
* that created the subagent). A raw session id never reaches the
|
|
551
522
|
* interface: an unknown name is a generic one.
|
|
552
523
|
*
|
|
524
|
+
* `parentSessionId` is kept: it is the exact identity of the session that
|
|
525
|
+
* created a subagent, so the card can count a parent and its subagents as
|
|
526
|
+
* ONE task (the rule of `lib/blockers.js`). It is null when the Host did
|
|
527
|
+
* not report one — the title is then the fallback.
|
|
528
|
+
*
|
|
553
529
|
* @param {any} entry the entry the Host produced
|
|
554
530
|
* @param {string} sessionId
|
|
555
|
-
* @returns {{ sessionId: string, kind: string, label: string|null, name: string, parentTitle: string|null, title: string }}
|
|
531
|
+
* @returns {{ sessionId: string, kind: string, label: string|null, name: string, parentTitle: string|null, parentSessionId: string|null, title: string }}
|
|
556
532
|
*/
|
|
557
533
|
const blockerEntry = (entry, sessionId) => {
|
|
558
534
|
const kind = entry.kind === 'subagent' ? 'subagent' : 'session';
|
|
@@ -569,12 +545,17 @@ export function createSleepController({
|
|
|
569
545
|
// writes the generic line when the name is empty.
|
|
570
546
|
if (name === '') name = label;
|
|
571
547
|
const parentTitle = typeof entry.parentTitle === 'string' ? entry.parentTitle.trim() : '';
|
|
548
|
+
const parentSessionId = typeof entry.parentSessionId === 'string' ? entry.parentSessionId.trim() : '';
|
|
572
549
|
return {
|
|
573
550
|
sessionId,
|
|
574
551
|
kind,
|
|
575
552
|
label: label !== '' ? label : null,
|
|
576
553
|
name,
|
|
577
554
|
parentTitle: parentTitle !== '' ? parentTitle : null,
|
|
555
|
+
// The identity of the session that created the subagent: it is what lets
|
|
556
|
+
// the card count a parent and its subagents as ONE task. It is only
|
|
557
|
+
// there when the Host knows it — a plain session keeps its old shape.
|
|
558
|
+
...(parentSessionId !== '' ? { parentSessionId } : {}),
|
|
578
559
|
// `title` is the old name of `label`: it stays, so an older client
|
|
579
560
|
// keeps working.
|
|
580
561
|
title: label !== '' ? label : name,
|
|
@@ -686,18 +667,31 @@ export function createSleepController({
|
|
|
686
667
|
* bug, so a list with a nameless entry in it is left out entirely: the
|
|
687
668
|
* caller then writes the line without the parenthetical.
|
|
688
669
|
*
|
|
670
|
+
* The names are the names of the TASKS: a session and its own subagents are
|
|
671
|
+
* ONE task, and it is named after the session itself (after its subagent
|
|
672
|
+
* when the session is idle and only the subagent works). So the list has as
|
|
673
|
+
* many names as the count has tasks.
|
|
674
|
+
*
|
|
689
675
|
* @param {any[]} list
|
|
690
676
|
* @returns {string} the joined names, or '' when the list cannot be named
|
|
691
677
|
*/
|
|
692
678
|
const names = (list) => {
|
|
693
|
-
const
|
|
694
|
-
|
|
679
|
+
const entries = Array.isArray(list) ? list : [];
|
|
680
|
+
const keys = blockerTaskKeys(entries);
|
|
681
|
+
const byTask = new Map();
|
|
682
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
683
|
+
const entry = entries[index];
|
|
695
684
|
const label = entry && typeof entry.label === 'string' ? entry.label.trim() : '';
|
|
696
685
|
const name = entry && typeof entry.name === 'string' ? entry.name.trim() : '';
|
|
697
686
|
if (label === '' || name === '') return '';
|
|
698
|
-
|
|
687
|
+
const key = keys[index] !== '' ? keys[index] : ('entry:' + index);
|
|
688
|
+
const isSubagent = entry.kind === 'subagent';
|
|
689
|
+
const known = byTask.get(key);
|
|
690
|
+
if (!known || (known.isSubagent && !isSubagent)) {
|
|
691
|
+
byTask.set(key, { name, isSubagent });
|
|
692
|
+
}
|
|
699
693
|
}
|
|
700
|
-
return
|
|
694
|
+
return Array.from(byTask.values()).map((item) => item.name).join('、');
|
|
701
695
|
};
|
|
702
696
|
|
|
703
697
|
/**
|
|
@@ -707,12 +701,15 @@ export function createSleepController({
|
|
|
707
701
|
* - one blocker with a title → the singular form ("等「甲」收工");
|
|
708
702
|
* - otherwise → the plain count, without a parenthetical.
|
|
709
703
|
*
|
|
704
|
+
* The count is about TASKS: a session and its own subagents are ONE task
|
|
705
|
+
* (the rule of `lib/blockers.js`), the same number the left sidebar shows.
|
|
706
|
+
*
|
|
710
707
|
* @param {any[]} list the blockers
|
|
711
708
|
* @param {{ immediate?: boolean }} options `immediate` = no countdown at all
|
|
712
709
|
* @returns {string}
|
|
713
710
|
*/
|
|
714
711
|
const waitingFor = (list, { immediate = false } = {}) => {
|
|
715
|
-
const count =
|
|
712
|
+
const count = countBlockerTasks(list);
|
|
716
713
|
const tail = immediate ? '收工后立即关机。' : '收工后开始倒数。';
|
|
717
714
|
const named = names(list);
|
|
718
715
|
if (count === 1 && named !== '') return `等「${named}」收工,${tail}`;
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — HOW THIS HOST WAS STARTED.
|
|
3
|
+
*
|
|
4
|
+
* A hosted web host can be started in a dozen ways, and the plugin has to
|
|
5
|
+
* behave correctly in EVERY one of them:
|
|
6
|
+
*
|
|
7
|
+
* | # | how it was started | what the plugin does |
|
|
8
|
+
* |---|---------------------------------------------|---------------------------------|
|
|
9
|
+
* | 1 | a plain terminal (`dsh web`) | own supervisor MAY be installed |
|
|
10
|
+
* | 2 | `nohup … &` / `setsid` (detached) | same, but the replay is detached|
|
|
11
|
+
* | 3 | a supervisor script (`supervise-*.sh`) | external → graceful exit only |
|
|
12
|
+
* | 4 | a systemd unit | external |
|
|
13
|
+
* | 5 | pm2 / forever | external |
|
|
14
|
+
* | 6 | docker / compose / kubernetes | no "at login"; compose fragment |
|
|
15
|
+
* | 7 | a Windows scheduled task / NSSM service | external |
|
|
16
|
+
* | 8 | WSL, started by a Windows process | unknown (the parent is invisible)|
|
|
17
|
+
* | 9 | `npx` / `pnpm dlx` | usable if the argv can be restored|
|
|
18
|
+
* |10 | another DSH plugin spawned it | external/unknown → never a second|
|
|
19
|
+
* |11 | tmux / screen / an SSH session | replay, but detached from the session |
|
|
20
|
+
*
|
|
21
|
+
* THE TWO RULES THAT MATTER MORE THAN THE TABLE:
|
|
22
|
+
*
|
|
23
|
+
* 1. **NEVER START A SECOND HOST.** Only a `bare` host may get a
|
|
24
|
+
* self-replay command, and only a `bare` host may get the supervisor of
|
|
25
|
+
* this plugin. Everything else is somebody else's business.
|
|
26
|
+
* 2. **WHEN IN DOUBT, BE CONSERVATIVE.** An `unknown` host gets
|
|
27
|
+
* `selfReplay: 'unknown'` and NO supervisor: the plugin would rather
|
|
28
|
+
* say "I cannot tell" than start a second host next to the first.
|
|
29
|
+
*
|
|
30
|
+
* The classifier is a PURE function: the process chain, the environment and
|
|
31
|
+
* the container markers are injected, so every row of the table can be
|
|
32
|
+
* tested without a machine.
|
|
33
|
+
*
|
|
34
|
+
* ESM only, Node builtins only.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** The modes the classifier can answer. */
|
|
38
|
+
export const STARTUP_MODES = Object.freeze({
|
|
39
|
+
/** The desktop app: it relaunches itself. */
|
|
40
|
+
DESKTOP: 'desktop',
|
|
41
|
+
/** The supervisor of THIS plugin started the instance. */
|
|
42
|
+
SELF_SUPERVISED: 'self-supervised',
|
|
43
|
+
/** Somebody else (systemd, pm2, a script, a service, another plugin). */
|
|
44
|
+
EXTERNAL: 'external',
|
|
45
|
+
/** Nobody manages it: a terminal, a detached process, a session. */
|
|
46
|
+
BARE: 'bare',
|
|
47
|
+
/** A container: the orchestrator decides, not the machine. */
|
|
48
|
+
CONTAINER: 'container',
|
|
49
|
+
/** It could not be established. */
|
|
50
|
+
UNKNOWN: 'unknown',
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
/** The kinds of a startup mode (the finer answer). */
|
|
54
|
+
export const STARTUP_KINDS = Object.freeze({
|
|
55
|
+
DESKTOP_APP: 'desktop-app',
|
|
56
|
+
OWN_SUPERVISOR: 'dsh-autorestart-supervisor',
|
|
57
|
+
SYSTEMD: 'systemd',
|
|
58
|
+
PM2: 'pm2',
|
|
59
|
+
FOREVER: 'forever',
|
|
60
|
+
SCRIPT: 'script',
|
|
61
|
+
WINDOWS_TASK: 'windows-task',
|
|
62
|
+
WINDOWS_SERVICE: 'windows-service',
|
|
63
|
+
DSH_PLUGIN: 'dsh-plugin',
|
|
64
|
+
DOCKER: 'docker',
|
|
65
|
+
KUBERNETES: 'kubernetes',
|
|
66
|
+
TERMINAL: 'terminal',
|
|
67
|
+
DETACHED: 'detached',
|
|
68
|
+
SESSION: 'session',
|
|
69
|
+
PACKAGE_RUNNER: 'package-runner',
|
|
70
|
+
WSL_FROM_WINDOWS: 'wsl-from-windows',
|
|
71
|
+
OPAQUE_WRAPPER: 'opaque-wrapper',
|
|
72
|
+
UNREADABLE_CHAIN: 'unreadable-chain',
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/** The reason codes of the derivation that follow from a startup mode. */
|
|
76
|
+
export const STARTUP_REASONS = Object.freeze({
|
|
77
|
+
DESKTOP: 'desktop-app',
|
|
78
|
+
OWN_SUPERVISOR: 'supervised',
|
|
79
|
+
EXTERNAL: 'external-supervisor',
|
|
80
|
+
BARE: 'ok',
|
|
81
|
+
CONTAINER: 'container-no-init',
|
|
82
|
+
UNKNOWN: 'unknown-startup',
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The environment markers of the common supervisors.
|
|
87
|
+
*
|
|
88
|
+
* They are cheap, exact and need no process tree.
|
|
89
|
+
*/
|
|
90
|
+
const SUPERVISOR_ENV_MARKERS = Object.freeze([
|
|
91
|
+
{ key: 'INVOCATION_ID', kind: STARTUP_KINDS.SYSTEMD, label: 'a systemd unit' },
|
|
92
|
+
{ key: 'pm_id', kind: STARTUP_KINDS.PM2, label: 'pm2' },
|
|
93
|
+
{ key: 'PM2_HOME', kind: STARTUP_KINDS.PM2, label: 'pm2' },
|
|
94
|
+
])
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The ancestors that mean "somebody else is in charge".
|
|
98
|
+
*
|
|
99
|
+
* The ORDER does not matter here: an external match ANYWHERE in the chain
|
|
100
|
+
* wins, because the nearest ancestor may be a plain shell that a supervisor
|
|
101
|
+
* started (`systemd → bash → dsh web`).
|
|
102
|
+
*/
|
|
103
|
+
const EXTERNAL_ANCESTORS = Object.freeze([
|
|
104
|
+
{ kind: STARTUP_KINDS.SYSTEMD, pattern: /systemd/i, label: 'a systemd unit' },
|
|
105
|
+
{ kind: STARTUP_KINDS.PM2, pattern: /\bpm2\b/i, label: 'pm2' },
|
|
106
|
+
{ kind: STARTUP_KINDS.FOREVER, pattern: /\bforever\b/i, label: 'forever' },
|
|
107
|
+
{
|
|
108
|
+
kind: STARTUP_KINDS.SCRIPT,
|
|
109
|
+
pattern: /supervise[-_][\w.-]*\.(sh|bash|py|js)|supervisord|\brunsv\b|\bs6-supervise\b|\bmonit\b/i,
|
|
110
|
+
label: 'a supervisor script',
|
|
111
|
+
},
|
|
112
|
+
{ kind: STARTUP_KINDS.WINDOWS_SERVICE, pattern: /\bnssm(\.exe)?\b/i, label: 'a Windows service (NSSM)' },
|
|
113
|
+
{
|
|
114
|
+
kind: STARTUP_KINDS.WINDOWS_TASK,
|
|
115
|
+
pattern: /\bsvchost(\.exe)?\b|\btaskhostw(\.exe)?\b/i,
|
|
116
|
+
label: 'the Windows side (a scheduled task or a service)',
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
kind: STARTUP_KINDS.DSH_PLUGIN,
|
|
120
|
+
pattern: /[\\/]plugins[\\/][^\\/]+[\\/]/i,
|
|
121
|
+
label: 'another DSH plugin',
|
|
122
|
+
},
|
|
123
|
+
])
|
|
124
|
+
|
|
125
|
+
/** The ancestors that mean "a session" (not a supervisor, but not free either). */
|
|
126
|
+
const SESSION_ANCESTORS = Object.freeze([
|
|
127
|
+
{ pattern: /\btmux\b/i, label: 'a tmux session' },
|
|
128
|
+
{ pattern: /\bscreen\b/i, label: 'a screen session' },
|
|
129
|
+
{ pattern: /\bsshd\b|\bssh\b/i, label: 'an SSH session' },
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
/** The ancestors that mean "the command was detached from its shell". */
|
|
133
|
+
const DETACHED_ANCESTORS = Object.freeze([
|
|
134
|
+
{ pattern: /\bnohup\b/i, label: 'a nohup command' },
|
|
135
|
+
{ pattern: /\bsetsid\b/i, label: 'a setsid command' },
|
|
136
|
+
])
|
|
137
|
+
|
|
138
|
+
/** The ancestors that mean "a human started it in a terminal". */
|
|
139
|
+
const TERMINAL_ANCESTORS = Object.freeze([
|
|
140
|
+
{ pattern: /\b(bash|sh|zsh|fish|dash|ksh|csh|tcsh)\b/i, label: 'a shell' },
|
|
141
|
+
{ pattern: /\b(cmd(\.exe)?|powershell(\.exe)?|pwsh(\.exe)?|conhost(\.exe)?|WindowsTerminal(\.exe)?)\b/i, label: 'a Windows terminal' },
|
|
142
|
+
])
|
|
143
|
+
|
|
144
|
+
/** The package runners that may hide the real command line. */
|
|
145
|
+
const PACKAGE_RUNNERS = Object.freeze([
|
|
146
|
+
{ pattern: /^(npx|pnpm|npm|yarn|bunx|dlx)(\.cmd|\.exe|\.ps1)?$/i, label: 'a package runner' },
|
|
147
|
+
])
|
|
148
|
+
|
|
149
|
+
/** The places in a path that say "a package runner was here". */
|
|
150
|
+
const PACKAGE_RUNNER_PATHS = Object.freeze([
|
|
151
|
+
/[\\/]_npx[\\/]/i,
|
|
152
|
+
/[\\/]\.pnpm[\\/]/i,
|
|
153
|
+
/[\\/]node_modules[\\/]\.bin[\\/]/i,
|
|
154
|
+
/[\\/]node_modules[\\/][^\\/]+[\\/]bin[\\/]/i,
|
|
155
|
+
])
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The text of one ancestor (the image name and the command line).
|
|
159
|
+
*
|
|
160
|
+
* @param {{ imageName?: string, commandLine?: string }} entry
|
|
161
|
+
* @returns {string}
|
|
162
|
+
*/
|
|
163
|
+
function ancestorText(entry) {
|
|
164
|
+
if (!entry || typeof entry !== 'object') return ''
|
|
165
|
+
return [entry.imageName ?? '', entry.commandLine ?? ''].filter(Boolean).join(' ')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The evidence of one ancestor: what it is and how it was found.
|
|
170
|
+
*
|
|
171
|
+
* @param {{ pid?: number, imageName?: string, commandLine?: string }} entry
|
|
172
|
+
* @returns {string}
|
|
173
|
+
*/
|
|
174
|
+
function ancestorEvidence(entry) {
|
|
175
|
+
const pid = Number(entry?.pid)
|
|
176
|
+
const text = ancestorText(entry).trim()
|
|
177
|
+
return 'ancestor:' + (Number.isFinite(pid) ? pid : '?') + ': ' + (text === '' ? '?' : text)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The first ancestor that matches one of the patterns.
|
|
182
|
+
*
|
|
183
|
+
* @param {Array<object>} chain
|
|
184
|
+
* @param {Array<{ pattern: RegExp, label: string, kind?: string }>} patterns
|
|
185
|
+
* @returns {{ entry: object, label: string, kind: string }|null}
|
|
186
|
+
*/
|
|
187
|
+
function firstMatch(chain, patterns) {
|
|
188
|
+
for (const entry of chain) {
|
|
189
|
+
const text = ancestorText(entry)
|
|
190
|
+
if (text === '') continue
|
|
191
|
+
for (const rule of patterns) {
|
|
192
|
+
if (rule.pattern.test(text)) {
|
|
193
|
+
return { entry, label: rule.label, kind: rule.kind ?? '' }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Is this a WSL instance?
|
|
202
|
+
*
|
|
203
|
+
* @param {object} env
|
|
204
|
+
* @returns {boolean}
|
|
205
|
+
*/
|
|
206
|
+
export function isWsl(env) {
|
|
207
|
+
if (!env || typeof env !== 'object') return false
|
|
208
|
+
if (typeof env.WSL_DISTRO_NAME === 'string' && env.WSL_DISTRO_NAME !== '') return true
|
|
209
|
+
if (typeof env.WSL_INTEROP === 'string' && env.WSL_INTEROP !== '') return true
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Can the real command line be restored?
|
|
215
|
+
*
|
|
216
|
+
* A package runner (`npx dsh web`) starts the host with the RESOLVED script
|
|
217
|
+
* path in `argv[1]` — that is replayable. A wrapper that erased the command
|
|
218
|
+
* line is not: the plugin must not guess which script it was.
|
|
219
|
+
*
|
|
220
|
+
* @param {{ argv?: string[], execPath?: string }} options
|
|
221
|
+
* @returns {{ ok: boolean, kind: string, label: string, reason: string }}
|
|
222
|
+
*/
|
|
223
|
+
export function inspectCommandLine(options = {}) {
|
|
224
|
+
const argv = Array.isArray(options.argv) ? options.argv.map(String) : []
|
|
225
|
+
const execPath = String(options.execPath ?? '')
|
|
226
|
+
const script = String(argv[1] ?? '').trim()
|
|
227
|
+
const runner = PACKAGE_RUNNERS.find((rule) => (
|
|
228
|
+
rule.pattern.test(argv[0] ?? '') || rule.pattern.test(execPath.split(/[\\/]/).pop() ?? '')
|
|
229
|
+
))
|
|
230
|
+
const runnerInPath = PACKAGE_RUNNER_PATHS.some((rule) => rule.test(script))
|
|
231
|
+
if (runner === undefined && !runnerInPath) {
|
|
232
|
+
// Not a package runner: the command line is used as it is.
|
|
233
|
+
if (script === '') {
|
|
234
|
+
return { ok: false, kind: STARTUP_KINDS.OPAQUE_WRAPPER, label: 'an unreadable command line', reason: 'the command line of this host cannot be read back' }
|
|
235
|
+
}
|
|
236
|
+
return { ok: true, kind: '', label: '', reason: '' }
|
|
237
|
+
}
|
|
238
|
+
const label = runner !== undefined ? runner.label : 'a package runner'
|
|
239
|
+
// A package runner: the resolved script path must be there, or nothing can
|
|
240
|
+
// be replayed.
|
|
241
|
+
if (script !== '' && (/[\\/]/.test(script) || /\.(mjs|cjs|js)$/i.test(script))) {
|
|
242
|
+
return { ok: true, kind: STARTUP_KINDS.PACKAGE_RUNNER, label, reason: '' }
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
ok: false,
|
|
246
|
+
kind: STARTUP_KINDS.OPAQUE_WRAPPER,
|
|
247
|
+
label,
|
|
248
|
+
reason: 'this host was started through ' + label + ', and the real command line cannot be restored: nothing is guessed',
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The command that starts the host again, DETACHED from the shell that runs
|
|
254
|
+
* it.
|
|
255
|
+
*
|
|
256
|
+
* A host that was started with `nohup … &` (or inside a tmux/ssh session)
|
|
257
|
+
* must not die with the shell that runs the command: the new host has to
|
|
258
|
+
* outlive it.
|
|
259
|
+
*
|
|
260
|
+
* @param {string} command
|
|
261
|
+
* @param {string} platform
|
|
262
|
+
* @returns {string}
|
|
263
|
+
*/
|
|
264
|
+
export function detachCommand(command, platform) {
|
|
265
|
+
const text = String(command ?? '').trim()
|
|
266
|
+
if (text === '') return ''
|
|
267
|
+
if (platform === 'win32') return 'start "" /B ' + text
|
|
268
|
+
return 'setsid nohup ' + text + ' >/dev/null 2>&1 &'
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Classify HOW this host was started.
|
|
273
|
+
*
|
|
274
|
+
* @param {{
|
|
275
|
+
* platform?: string,
|
|
276
|
+
* env?: object,
|
|
277
|
+
* ppid?: number,
|
|
278
|
+
* isDesktopApp?: boolean,
|
|
279
|
+
* supervised?: boolean,
|
|
280
|
+
* container?: boolean,
|
|
281
|
+
* argv?: string[],
|
|
282
|
+
* execPath?: string,
|
|
283
|
+
* chain?: Array<object>,
|
|
284
|
+
* readChain?: Function,
|
|
285
|
+
* }} [options]
|
|
286
|
+
* @returns {{
|
|
287
|
+
* mode: string,
|
|
288
|
+
* kind: string,
|
|
289
|
+
* evidence: string,
|
|
290
|
+
* selfReplay: 'allowed'|'refused'|'unknown'|'n/a',
|
|
291
|
+
* canInstallSupervisor: boolean,
|
|
292
|
+
* detachCommand: boolean,
|
|
293
|
+
* suggestion: string,
|
|
294
|
+
* chain: Array<object>,
|
|
295
|
+
* }}
|
|
296
|
+
*/
|
|
297
|
+
export function classifyStartup(options = {}) {
|
|
298
|
+
const platform = typeof options.platform === 'string' ? options.platform : process.platform
|
|
299
|
+
const env = options.env && typeof options.env === 'object' ? options.env : process.env
|
|
300
|
+
const chain = Array.isArray(options.chain)
|
|
301
|
+
? options.chain
|
|
302
|
+
: readChainSafely(options)
|
|
303
|
+
const wsl = options.wsl === true || (options.wsl === undefined && isWsl(env))
|
|
304
|
+
|
|
305
|
+
const answer = (mode, kind, evidence, selfReplay, canInstallSupervisor, suggestion, extra = {}) => ({
|
|
306
|
+
mode,
|
|
307
|
+
kind,
|
|
308
|
+
evidence,
|
|
309
|
+
selfReplay,
|
|
310
|
+
canInstallSupervisor,
|
|
311
|
+
detachCommand: extra.detachCommand === true,
|
|
312
|
+
suggestion,
|
|
313
|
+
chain,
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
// (1) THE DESKTOP APP. It relaunches itself; nobody may install anything.
|
|
317
|
+
if (options.isDesktopApp === true) {
|
|
318
|
+
return answer(
|
|
319
|
+
STARTUP_MODES.DESKTOP,
|
|
320
|
+
STARTUP_KINDS.DESKTOP_APP,
|
|
321
|
+
'the executable of this process is the desktop client',
|
|
322
|
+
'n/a',
|
|
323
|
+
false,
|
|
324
|
+
'the desktop app relaunches itself (restartMode: app) — there is no supervisor to install and no command to write',
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// (2) OUR OWN SUPERVISOR started this instance: the restart is asked of it.
|
|
329
|
+
if (options.supervised === true) {
|
|
330
|
+
return answer(
|
|
331
|
+
STARTUP_MODES.SELF_SUPERVISED,
|
|
332
|
+
STARTUP_KINDS.OWN_SUPERVISOR,
|
|
333
|
+
'the environment marker DSH_AUTORESTART_SUPERVISED says who started this instance',
|
|
334
|
+
'n/a',
|
|
335
|
+
false,
|
|
336
|
+
'the supervisor of this plugin is in charge: the restart is handed to it',
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// (3) A CONTAINER. There is no "at login" inside a container; the
|
|
341
|
+
// orchestrator decides (compose: restart: always).
|
|
342
|
+
if (options.container === true) {
|
|
343
|
+
const kubernetes = typeof env.KUBERNETES_SERVICE_HOST === 'string' && env.KUBERNETES_SERVICE_HOST !== ''
|
|
344
|
+
return answer(
|
|
345
|
+
STARTUP_MODES.CONTAINER,
|
|
346
|
+
kubernetes ? STARTUP_KINDS.KUBERNETES : STARTUP_KINDS.DOCKER,
|
|
347
|
+
kubernetes ? 'the environment marker KUBERNETES_SERVICE_HOST' : 'the container markers of this process',
|
|
348
|
+
'refused',
|
|
349
|
+
false,
|
|
350
|
+
'this host runs in a container: there is no "at login" here — the orchestrator brings it back (compose: restart: always)',
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// (4) THE ENVIRONMENT MARKERS of the common supervisors.
|
|
355
|
+
for (const marker of SUPERVISOR_ENV_MARKERS) {
|
|
356
|
+
const value = env[marker.key]
|
|
357
|
+
if (typeof value === 'string' && value !== '') {
|
|
358
|
+
return answer(
|
|
359
|
+
STARTUP_MODES.EXTERNAL,
|
|
360
|
+
marker.kind,
|
|
361
|
+
'env:' + marker.key,
|
|
362
|
+
'refused',
|
|
363
|
+
false,
|
|
364
|
+
'this host is started by ' + marker.label + ' — it brings the host back, so the plugin only exits gracefully',
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// (5) THE PROCESS CHAIN. An external match ANYWHERE in the chain wins: the
|
|
370
|
+
// nearest ancestor may be a plain shell that a supervisor started.
|
|
371
|
+
const external = firstMatch(chain, EXTERNAL_ANCESTORS)
|
|
372
|
+
if (external !== null) {
|
|
373
|
+
return answer(
|
|
374
|
+
STARTUP_MODES.EXTERNAL,
|
|
375
|
+
external.kind === '' ? STARTUP_KINDS.SCRIPT : external.kind,
|
|
376
|
+
ancestorEvidence(external.entry),
|
|
377
|
+
'refused',
|
|
378
|
+
false,
|
|
379
|
+
'this host is started by ' + external.label + ' — it brings the host back, so the plugin only exits gracefully',
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// (6) WSL: a host that the WINDOWS side started has no visible parent
|
|
384
|
+
// here. It must NOT be treated as a bare host.
|
|
385
|
+
if (wsl && (chain.length === 0 || chain.every((entry) => Number(entry.pid) <= 1))) {
|
|
386
|
+
return answer(
|
|
387
|
+
STARTUP_MODES.UNKNOWN,
|
|
388
|
+
STARTUP_KINDS.WSL_FROM_WINDOWS,
|
|
389
|
+
chain.length === 0
|
|
390
|
+
? 'the parent chain cannot be read (in WSL the Windows side is invisible)'
|
|
391
|
+
: 'the nearest ancestor is the WSL init — the Windows side that started it is invisible',
|
|
392
|
+
'unknown',
|
|
393
|
+
false,
|
|
394
|
+
'this host runs in WSL and was started by a Windows process: the parent is invisible here — pick the plain terminal (install your own supervisor) or configure the start command yourself',
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// (7) THE COMMAND LINE. A package runner that hid the real command line
|
|
399
|
+
// cannot be replayed — and must not be guessed.
|
|
400
|
+
const command = inspectCommandLine({ argv: options.argv, execPath: options.execPath })
|
|
401
|
+
if (!command.ok) {
|
|
402
|
+
return answer(
|
|
403
|
+
STARTUP_MODES.UNKNOWN,
|
|
404
|
+
command.kind,
|
|
405
|
+
command.reason,
|
|
406
|
+
'unknown',
|
|
407
|
+
false,
|
|
408
|
+
'this host was started through ' + command.label + ' and the real command line cannot be restored: nothing is guessed, and no supervisor is installed',
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// (8) A SESSION (tmux / screen / ssh): nobody restarts the host, so a
|
|
413
|
+
// replay is possible — but it must be detached from the session.
|
|
414
|
+
const session = firstMatch(chain, SESSION_ANCESTORS)
|
|
415
|
+
if (session !== null) {
|
|
416
|
+
return answer(
|
|
417
|
+
STARTUP_MODES.BARE,
|
|
418
|
+
STARTUP_KINDS.SESSION,
|
|
419
|
+
ancestorEvidence(session.entry),
|
|
420
|
+
'allowed',
|
|
421
|
+
true,
|
|
422
|
+
'this host runs in ' + session.label + ': nobody brings it back, so the restart command must be detached from the session (setsid/nohup) and needs your confirmation',
|
|
423
|
+
{ detachCommand: true },
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// (9) A DETACHED start (`nohup … &`): nobody brings it back either, but
|
|
428
|
+
// the replay must not die with the shell that runs it.
|
|
429
|
+
const detached = firstMatch(chain, DETACHED_ANCESTORS)
|
|
430
|
+
if (detached !== null) {
|
|
431
|
+
return answer(
|
|
432
|
+
STARTUP_MODES.BARE,
|
|
433
|
+
STARTUP_KINDS.DETACHED,
|
|
434
|
+
ancestorEvidence(detached.entry),
|
|
435
|
+
'allowed',
|
|
436
|
+
true,
|
|
437
|
+
'this host was started detached (' + detached.label + '): the restart command must outlive the shell that runs it (setsid/nohup)',
|
|
438
|
+
{ detachCommand: true },
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// (10) A PLAIN TERMINAL: nobody manages it. This is the only case in
|
|
443
|
+
// which the plugin may install its own supervisor.
|
|
444
|
+
if (chain.length > 0) {
|
|
445
|
+
const nearest = chain[0]
|
|
446
|
+
const terminal = firstMatch(chain, TERMINAL_ANCESTORS)
|
|
447
|
+
const label = terminal !== null ? terminal.label : 'the process that started it'
|
|
448
|
+
return answer(
|
|
449
|
+
STARTUP_MODES.BARE,
|
|
450
|
+
command.kind === STARTUP_KINDS.PACKAGE_RUNNER ? STARTUP_KINDS.PACKAGE_RUNNER : STARTUP_KINDS.TERMINAL,
|
|
451
|
+
'parent:' + (Number.isFinite(Number(nearest.pid)) ? nearest.pid : '?') + ': '
|
|
452
|
+
+ (nearest.imageName !== '' ? nearest.imageName : label)
|
|
453
|
+
+ (command.kind === STARTUP_KINDS.PACKAGE_RUNNER ? ' (' + command.label + ')' : ''),
|
|
454
|
+
'allowed',
|
|
455
|
+
true,
|
|
456
|
+
'this host was started by hand in ' + label + ': nobody brings it back, so the plugin may install its own supervisor — or write down the command that starts it again (after your confirmation)',
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// (11) NOTHING COULD BE READ. Say so, and stay conservative.
|
|
461
|
+
return answer(
|
|
462
|
+
STARTUP_MODES.UNKNOWN,
|
|
463
|
+
STARTUP_KINDS.UNREADABLE_CHAIN,
|
|
464
|
+
platform === 'win32'
|
|
465
|
+
? 'the process table of Windows could not be read: who starts this host is unknown'
|
|
466
|
+
: 'the parent chain cannot be read: who starts this host is unknown',
|
|
467
|
+
'unknown',
|
|
468
|
+
false,
|
|
469
|
+
'it could not be established who starts this host: nothing is written and no supervisor is installed',
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Read the parent chain without ever throwing.
|
|
475
|
+
*
|
|
476
|
+
* @param {{ ppid?: number, platform?: string, readChain?: Function }} options
|
|
477
|
+
* @returns {Array<object>}
|
|
478
|
+
*/
|
|
479
|
+
function readChainSafely(options) {
|
|
480
|
+
const platform = typeof options.platform === 'string' ? options.platform : process.platform
|
|
481
|
+
const ppid = Number(options.ppid)
|
|
482
|
+
if (!Number.isFinite(ppid) || ppid <= 0) return []
|
|
483
|
+
const read = typeof options.readChain === 'function' ? options.readChain : null
|
|
484
|
+
if (read === null) return []
|
|
485
|
+
try {
|
|
486
|
+
const chain = read(ppid, { platform })
|
|
487
|
+
return Array.isArray(chain) ? chain : []
|
|
488
|
+
} catch {
|
|
489
|
+
return []
|
|
490
|
+
}
|
|
491
|
+
}
|