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.
- package/README.md +460 -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 +6329 -3863
- package/cordis.patch.yml +44 -1
- package/docs/README.zh.md +297 -9
- package/docs/platform-matrix.md +92 -0
- package/docs/rescue-drill.md +606 -0
- package/index.js +2867 -199
- 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 +188 -0
- package/lib/health.js +264 -0
- package/lib/host-kind.js +145 -0
- package/lib/node-runtime.js +144 -0
- package/lib/rescue-paths.js +253 -0
- package/lib/rescue.js +596 -0
- package/lib/restart-verdict.js +327 -0
- package/lib/restart.js +823 -56
- package/lib/scheduler-capability.js +300 -0
- 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/lib/watchdog-arbiter.js +117 -0
- package/lib/watchdog-schedule.js +310 -0
- package/lib/watchdog-tier.js +182 -0
- package/lib/watchdog.js +479 -0
- package/locale/en.json +274 -64
- package/locale/zh.json +274 -64
- package/package.json +7 -2
- package/screenshots.json +6 -4
- package/scripts/README.md +65 -0
- package/scripts/drill-rescue.ps1 +508 -0
- package/scripts/emergency-disable.cmd +27 -0
- package/scripts/emergency-toggle.ps1 +373 -0
- package/scripts/restore-profile.cmd +44 -0
- package/scripts/restore-profile.ps1 +608 -0
- package/scripts/run-tests.ps1 +46 -0
- package/supervisor/supervisor.js +884 -0
package/lib/health.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — "is the new host really up?".
|
|
3
|
+
*
|
|
4
|
+
* A restart is only successful when the NEW instance answers its health route.
|
|
5
|
+
* The old instance's answer proves nothing: the old instance may still be
|
|
6
|
+
* running (the restart command did nothing), or it may answer on a port the
|
|
7
|
+
* new instance does not use (the desktop app listens on a random port).
|
|
8
|
+
*
|
|
9
|
+
* So the confirmation is built from two facts:
|
|
10
|
+
*
|
|
11
|
+
* 1. the INSTANCE RECORD (lib/access.js) of the new instance — it carries a
|
|
12
|
+
* boot id that is different from the one of the instance that is being
|
|
13
|
+
* replaced, and the port the new instance really listens on;
|
|
14
|
+
* 2. the health route of THAT port.
|
|
15
|
+
*
|
|
16
|
+
* The wait is bounded: when the new instance does not answer in time the
|
|
17
|
+
* caller gets a REASON, never a silent success.
|
|
18
|
+
*
|
|
19
|
+
* ESM only, Node builtins only. No DSH / npm imports.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import http from 'node:http';
|
|
23
|
+
import { URL } from 'node:url';
|
|
24
|
+
|
|
25
|
+
import { readAccessRecordFile } from './access.js';
|
|
26
|
+
|
|
27
|
+
/** How long one health request may take before it counts as "no answer". */
|
|
28
|
+
export const DEFAULT_HEALTH_TIMEOUT_MS = 5000;
|
|
29
|
+
|
|
30
|
+
/** How long the new instance may take to come up at all. */
|
|
31
|
+
export const DEFAULT_INSTANCE_WAIT_MS = 90000;
|
|
32
|
+
|
|
33
|
+
/** How often the instance record is read while waiting. */
|
|
34
|
+
export const DEFAULT_INSTANCE_POLL_MS = 1000;
|
|
35
|
+
|
|
36
|
+
/** @param {unknown} value */
|
|
37
|
+
function errorText(value) {
|
|
38
|
+
if (value && typeof value.message === 'string' && value.message) return value.message;
|
|
39
|
+
return String(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Sleep for a while. Never rejects.
|
|
44
|
+
*
|
|
45
|
+
* @param {number} ms
|
|
46
|
+
* @returns {Promise<void>}
|
|
47
|
+
*/
|
|
48
|
+
function sleep(ms) {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
const timer = setTimeout(resolve, Math.max(0, Number(ms) || 0));
|
|
51
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One plain HTTP GET, answered with the parsed JSON body.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} url
|
|
59
|
+
* @param {number} timeoutMs
|
|
60
|
+
* @returns {Promise<{ ok: boolean, status: number, body: any, error: string|null }>}
|
|
61
|
+
*/
|
|
62
|
+
function httpGetJson(url, timeoutMs) {
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
let settled = false;
|
|
65
|
+
const finish = (result) => {
|
|
66
|
+
if (settled) return;
|
|
67
|
+
settled = true;
|
|
68
|
+
resolve(result);
|
|
69
|
+
};
|
|
70
|
+
let request;
|
|
71
|
+
try {
|
|
72
|
+
request = http.get(url, { timeout: timeoutMs }, (response) => {
|
|
73
|
+
const chunks = [];
|
|
74
|
+
response.on('data', (chunk) => chunks.push(chunk));
|
|
75
|
+
response.on('end', () => {
|
|
76
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
77
|
+
let body = null;
|
|
78
|
+
try {
|
|
79
|
+
body = JSON.parse(text);
|
|
80
|
+
} catch {
|
|
81
|
+
body = null;
|
|
82
|
+
}
|
|
83
|
+
const status = Number(response.statusCode) || 0;
|
|
84
|
+
finish({
|
|
85
|
+
ok: status >= 200 && status < 300 && body !== null && body.ok === true,
|
|
86
|
+
status,
|
|
87
|
+
body,
|
|
88
|
+
error: status >= 200 && status < 300
|
|
89
|
+
? (body !== null && body.ok === true ? null : 'the health route did not answer ok')
|
|
90
|
+
: 'the health route answered with status ' + status,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
response.on('error', (error) => {
|
|
94
|
+
finish({ ok: false, status: 0, body: null, error: errorText(error) });
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
} catch (error) {
|
|
98
|
+
finish({ ok: false, status: 0, body: null, error: errorText(error) });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
request.on('timeout', () => {
|
|
102
|
+
try {
|
|
103
|
+
request.destroy();
|
|
104
|
+
} catch {
|
|
105
|
+
/* nothing else to do */
|
|
106
|
+
}
|
|
107
|
+
finish({ ok: false, status: 0, body: null, error: 'the health request timed out' });
|
|
108
|
+
});
|
|
109
|
+
request.on('error', (error) => {
|
|
110
|
+
finish({ ok: false, status: 0, body: null, error: errorText(error) });
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Ask one health route whether the host behind it is alive.
|
|
117
|
+
*
|
|
118
|
+
* @param {{ url?: string, timeoutMs?: number, requestImpl?: Function }} [options]
|
|
119
|
+
* @returns {Promise<{ ok: boolean, status: number, error: string|null }>}
|
|
120
|
+
*/
|
|
121
|
+
export function probeHealth({ url = '', timeoutMs = DEFAULT_HEALTH_TIMEOUT_MS, requestImpl = null } = {}) {
|
|
122
|
+
const target = typeof url === 'string' ? url.trim() : '';
|
|
123
|
+
if (target === '') {
|
|
124
|
+
return Promise.resolve({ ok: false, status: 0, error: 'probeHealth requires a url' });
|
|
125
|
+
}
|
|
126
|
+
const budget = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
|
|
127
|
+
? Math.trunc(Number(timeoutMs))
|
|
128
|
+
: DEFAULT_HEALTH_TIMEOUT_MS;
|
|
129
|
+
const request = typeof requestImpl === 'function' ? requestImpl : httpGetJson;
|
|
130
|
+
return new Promise((resolve) => {
|
|
131
|
+
let settled = false;
|
|
132
|
+
const finish = (result) => {
|
|
133
|
+
if (settled) return;
|
|
134
|
+
settled = true;
|
|
135
|
+
resolve(result);
|
|
136
|
+
};
|
|
137
|
+
const timer = setTimeout(() => {
|
|
138
|
+
finish({ ok: false, status: 0, error: 'the health request timed out after ' + budget + 'ms' });
|
|
139
|
+
}, budget);
|
|
140
|
+
Promise.resolve()
|
|
141
|
+
.then(() => request(target, budget))
|
|
142
|
+
.then((response) => {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
const status = Number(response && response.status) || 0;
|
|
145
|
+
if (response && response.ok === true) {
|
|
146
|
+
finish({ ok: true, status: status || 200, error: null });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
finish({
|
|
150
|
+
ok: false,
|
|
151
|
+
status,
|
|
152
|
+
error: (response && response.error) || 'the health route did not answer ok',
|
|
153
|
+
});
|
|
154
|
+
})
|
|
155
|
+
.catch((error) => {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
finish({ ok: false, status: 0, error: errorText(error) });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The health URL of one port on the loopback interface.
|
|
164
|
+
*
|
|
165
|
+
* @param {number|string} port
|
|
166
|
+
* @param {string} healthPath
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
export function healthUrlForPort(port, healthPath) {
|
|
170
|
+
const number = Number(port);
|
|
171
|
+
if (!Number.isFinite(number) || number <= 0) return '';
|
|
172
|
+
const path = typeof healthPath === 'string' && healthPath.startsWith('/')
|
|
173
|
+
? healthPath
|
|
174
|
+
: '/__dsh_autorestart/health';
|
|
175
|
+
return 'http://127.0.0.1:' + Math.trunc(number) + path;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Wait until the instance that replaced `expectedBootId` answers its health
|
|
180
|
+
* route.
|
|
181
|
+
*
|
|
182
|
+
* The instance record is the only source of the new port: the desktop app
|
|
183
|
+
* listens on a random port, so the port of the old instance is meaningless.
|
|
184
|
+
*
|
|
185
|
+
* @param {{
|
|
186
|
+
* accessFile?: string,
|
|
187
|
+
* expectedBootId?: string|null,
|
|
188
|
+
* healthPath?: string,
|
|
189
|
+
* timeoutMs?: number,
|
|
190
|
+
* intervalMs?: number,
|
|
191
|
+
* probe?: Function,
|
|
192
|
+
* readRecord?: Function,
|
|
193
|
+
* now?: Function,
|
|
194
|
+
* sleep?: Function,
|
|
195
|
+
* }} [options]
|
|
196
|
+
* @returns {Promise<{ ok: boolean, bootId: string|null, port: number|null, url: string|null, reason: string }>}
|
|
197
|
+
*/
|
|
198
|
+
export async function waitForHealthyInstance({
|
|
199
|
+
accessFile = '',
|
|
200
|
+
expectedBootId = null,
|
|
201
|
+
healthPath = '/__dsh_autorestart/health',
|
|
202
|
+
timeoutMs = DEFAULT_INSTANCE_WAIT_MS,
|
|
203
|
+
intervalMs = DEFAULT_INSTANCE_POLL_MS,
|
|
204
|
+
probe = probeHealth,
|
|
205
|
+
readRecord = readAccessRecordFile,
|
|
206
|
+
now = () => Date.now(),
|
|
207
|
+
sleep: sleepFn = sleep,
|
|
208
|
+
} = {}) {
|
|
209
|
+
const file = typeof accessFile === 'string' ? accessFile.trim() : '';
|
|
210
|
+
const budget = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
|
|
211
|
+
? Math.trunc(Number(timeoutMs))
|
|
212
|
+
: DEFAULT_INSTANCE_WAIT_MS;
|
|
213
|
+
const step = Number.isFinite(Number(intervalMs)) && Number(intervalMs) > 0
|
|
214
|
+
? Math.trunc(Number(intervalMs))
|
|
215
|
+
: DEFAULT_INSTANCE_POLL_MS;
|
|
216
|
+
const expected = typeof expectedBootId === 'string' && expectedBootId !== '' ? expectedBootId : null;
|
|
217
|
+
const deadline = now() + budget;
|
|
218
|
+
let lastReason = 'the new instance did not come up in time';
|
|
219
|
+
|
|
220
|
+
if (file === '') {
|
|
221
|
+
return { ok: false, bootId: null, port: null, url: null, reason: 'waitForHealthyInstance requires the instance record' };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
while (now() < deadline) {
|
|
225
|
+
const read = readRecord(file);
|
|
226
|
+
const record = read && read.ok === true ? read.record : null;
|
|
227
|
+
const bootId = record && typeof record.bootId === 'string' ? record.bootId : null;
|
|
228
|
+
const port = record && Number.isFinite(Number(record.port)) ? Number(record.port) : null;
|
|
229
|
+
const isNewInstance = bootId !== null && (expected === null || bootId !== expected);
|
|
230
|
+
if (isNewInstance && port !== null && port > 0) {
|
|
231
|
+
const url = healthUrlForPort(port, healthPath);
|
|
232
|
+
const answer = await probe({ url, timeoutMs: DEFAULT_HEALTH_TIMEOUT_MS });
|
|
233
|
+
if (answer && answer.ok === true) {
|
|
234
|
+
return { ok: true, bootId, port, url, reason: 'the new instance answered its health route' };
|
|
235
|
+
}
|
|
236
|
+
lastReason = (answer && answer.error) || 'the new instance did not answer its health route';
|
|
237
|
+
} else if (record === null) {
|
|
238
|
+
lastReason = 'the instance record is not readable yet';
|
|
239
|
+
} else if (!isNewInstance) {
|
|
240
|
+
lastReason = 'the instance that answers is still the one that is being replaced';
|
|
241
|
+
} else {
|
|
242
|
+
lastReason = 'the new instance did not publish its port yet';
|
|
243
|
+
}
|
|
244
|
+
await sleepFn(step);
|
|
245
|
+
}
|
|
246
|
+
return { ok: false, bootId: null, port: null, url: null, reason: lastReason };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The token of an access URL, or null.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} url
|
|
253
|
+
* @returns {string|null}
|
|
254
|
+
*/
|
|
255
|
+
export function tokenOf(url) {
|
|
256
|
+
const text = typeof url === 'string' ? url.trim() : '';
|
|
257
|
+
if (text === '') return null;
|
|
258
|
+
try {
|
|
259
|
+
const parsed = new URL(text);
|
|
260
|
+
return parsed.searchParams.get('token');
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
package/lib/host-kind.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — WHICH MACHINE IS THIS?
|
|
3
|
+
*
|
|
4
|
+
* The plugin has to know whether it runs on Windows, on a real Linux, on a
|
|
5
|
+
* macOS or INSIDE WSL (the Linux of Windows). The answer changes what may be
|
|
6
|
+
* done:
|
|
7
|
+
*
|
|
8
|
+
* - the graceful shutdown must reach the PHYSICAL machine (in WSL, shutting
|
|
9
|
+
* the distro down only stops the subsystem);
|
|
10
|
+
* - the "start at login" of the supervisor is a different thing in WSL
|
|
11
|
+
* (a systemd user unit + `loginctl enable-linger`, or a Windows-side
|
|
12
|
+
* task) than on a real Linux;
|
|
13
|
+
* - inside WSL there is NO Docker/Compose answer: the orchestrator is
|
|
14
|
+
* Windows itself.
|
|
15
|
+
*
|
|
16
|
+
* So the detection lives HERE, once, and both halves of the plugin use it.
|
|
17
|
+
*
|
|
18
|
+
* Everything is dependency-injected (the platform, the environment, the file
|
|
19
|
+
* probes), so the whole matrix can be tested without a machine.
|
|
20
|
+
*
|
|
21
|
+
* ESM only, Node builtins only.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs'
|
|
25
|
+
|
|
26
|
+
/** The Windows shutdown binary as it is reachable from WSL. */
|
|
27
|
+
export const WSL_SHUTDOWN_EXE = '/mnt/c/Windows/System32/shutdown.exe'
|
|
28
|
+
|
|
29
|
+
/** The file that tells a WSL kernel from a real Linux kernel. */
|
|
30
|
+
export const WSL_PROC_VERSION = '/proc/version'
|
|
31
|
+
|
|
32
|
+
/** The kinds of host the plugin knows. */
|
|
33
|
+
export const HOST_KINDS = Object.freeze({
|
|
34
|
+
WINDOWS: 'windows',
|
|
35
|
+
WSL: 'wsl',
|
|
36
|
+
LINUX: 'linux',
|
|
37
|
+
MACOS: 'macos',
|
|
38
|
+
UNSUPPORTED: 'unsupported',
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/** @param {unknown} value */
|
|
42
|
+
function nonEmptyString(value) {
|
|
43
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : ''
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The evidence that this Linux is really a WSL distribution.
|
|
48
|
+
*
|
|
49
|
+
* Any ONE of them is enough:
|
|
50
|
+
* 1. `WSL_DISTRO_NAME` is set (WSL exports it into every process),
|
|
51
|
+
* 2. `WSL_INTEROP` is set (WSL exports it into every process),
|
|
52
|
+
* 3. `/proc/version` mentions "microsoft" (the WSL kernel says so),
|
|
53
|
+
* 4. the Windows shutdown binary is reachable under `/mnt/c`.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} env
|
|
56
|
+
* @param {(file: string) => string} readFile
|
|
57
|
+
* @param {(file: string) => boolean} exists
|
|
58
|
+
* @returns {string|null} the name of the evidence, or null
|
|
59
|
+
*/
|
|
60
|
+
export function detectWslEvidence(env, readFile, exists) {
|
|
61
|
+
const environ = env && typeof env === 'object' ? env : {}
|
|
62
|
+
if (nonEmptyString(environ.WSL_DISTRO_NAME) !== '') return 'WSL_DISTRO_NAME'
|
|
63
|
+
if (nonEmptyString(environ.WSL_INTEROP) !== '') return 'WSL_INTEROP'
|
|
64
|
+
let version = ''
|
|
65
|
+
try {
|
|
66
|
+
version = String(readFile(WSL_PROC_VERSION) || '')
|
|
67
|
+
} catch {
|
|
68
|
+
version = ''
|
|
69
|
+
}
|
|
70
|
+
if (version.toLowerCase().includes('microsoft')) return WSL_PROC_VERSION
|
|
71
|
+
let found = false
|
|
72
|
+
try {
|
|
73
|
+
found = exists(WSL_SHUTDOWN_EXE) === true
|
|
74
|
+
} catch {
|
|
75
|
+
found = false
|
|
76
|
+
}
|
|
77
|
+
if (found) return WSL_SHUTDOWN_EXE
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Which machine is this?
|
|
83
|
+
*
|
|
84
|
+
* `suspect` is true when the ONLY evidence is the mounted Windows drive: a
|
|
85
|
+
* real Linux with a manually mounted C: drive looks exactly the same. The
|
|
86
|
+
* plugin says so instead of pretending to be sure — and it does NOT change
|
|
87
|
+
* any system configuration on such a guess.
|
|
88
|
+
*
|
|
89
|
+
* @param {{
|
|
90
|
+
* platform?: string,
|
|
91
|
+
* env?: object,
|
|
92
|
+
* readFile?: (file: string) => string,
|
|
93
|
+
* exists?: (file: string) => boolean,
|
|
94
|
+
* }} [options]
|
|
95
|
+
* @returns {{
|
|
96
|
+
* kind: 'windows'|'wsl'|'linux'|'macos'|'unsupported',
|
|
97
|
+
* platform: string,
|
|
98
|
+
* evidence: string,
|
|
99
|
+
* suspect: boolean,
|
|
100
|
+
* distro: string,
|
|
101
|
+
* }}
|
|
102
|
+
*/
|
|
103
|
+
export function detectHostKind({
|
|
104
|
+
platform = process.platform,
|
|
105
|
+
env = process.env,
|
|
106
|
+
readFile,
|
|
107
|
+
exists,
|
|
108
|
+
} = {}) {
|
|
109
|
+
const plat = nonEmptyString(platform) || process.platform
|
|
110
|
+
const environ = env && typeof env === 'object' ? env : process.env
|
|
111
|
+
const readFileSafe = typeof readFile === 'function'
|
|
112
|
+
? readFile
|
|
113
|
+
: (file) => fs.readFileSync(file, 'utf8')
|
|
114
|
+
const existsSafe = typeof exists === 'function'
|
|
115
|
+
? exists
|
|
116
|
+
: (file) => fs.existsSync(file)
|
|
117
|
+
|
|
118
|
+
const base = {
|
|
119
|
+
platform: plat,
|
|
120
|
+
evidence: 'process.platform',
|
|
121
|
+
suspect: false,
|
|
122
|
+
distro: nonEmptyString(environ.WSL_DISTRO_NAME),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (plat === 'win32') {
|
|
126
|
+
return { ...base, kind: HOST_KINDS.WINDOWS }
|
|
127
|
+
}
|
|
128
|
+
if (plat === 'linux') {
|
|
129
|
+
const evidence = detectWslEvidence(environ, readFileSafe, existsSafe)
|
|
130
|
+
if (evidence !== null) {
|
|
131
|
+
return {
|
|
132
|
+
...base,
|
|
133
|
+
kind: HOST_KINDS.WSL,
|
|
134
|
+
evidence,
|
|
135
|
+
// Only the mounted drive: that is a HINT, not a proof.
|
|
136
|
+
suspect: evidence === WSL_SHUTDOWN_EXE,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { ...base, kind: HOST_KINDS.LINUX }
|
|
140
|
+
}
|
|
141
|
+
if (plat === 'darwin') {
|
|
142
|
+
return { ...base, kind: HOST_KINDS.MACOS }
|
|
143
|
+
}
|
|
144
|
+
return { ...base, kind: HOST_KINDS.UNSUPPORTED }
|
|
145
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — the NODE INTERPRETER the supervisor is started with.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS FILE EXISTS:
|
|
5
|
+
*
|
|
6
|
+
* The supervisor is a plain node script. The record that starts it at login
|
|
7
|
+
* must therefore name a REAL node interpreter. `process.execPath` is NOT
|
|
8
|
+
* always node:
|
|
9
|
+
*
|
|
10
|
+
* - in the hosted web host it is node (`/usr/bin/node`, `node.exe`);
|
|
11
|
+
* - in the DESKTOP APP it is the Electron client
|
|
12
|
+
* (`…\DeepSeek Harness.exe`).
|
|
13
|
+
*
|
|
14
|
+
* A login record that starts the Electron client would open a SECOND copy of
|
|
15
|
+
* the client at every login (the login record inherits the environment of the
|
|
16
|
+
* user, which does NOT carry `ELECTRON_RUN_AS_NODE=1`, so the exe starts the
|
|
17
|
+
* full application instead of running the script as node).
|
|
18
|
+
*
|
|
19
|
+
* So the interpreter is RESOLVED, never guessed:
|
|
20
|
+
*
|
|
21
|
+
* 1. the process itself IS node (the file name starts with `node`);
|
|
22
|
+
* 2. the runtime that ships with the installation
|
|
23
|
+
* (`resources/runtime/primary-runtime/dependencies/node/bin/node.exe`);
|
|
24
|
+
* 3. the `node` of the PATH.
|
|
25
|
+
*
|
|
26
|
+
* When none of them is there the answer is "no interpreter": the caller
|
|
27
|
+
* refuses the installation and says why. The application executable is NEVER
|
|
28
|
+
* used as a stand-in for node.
|
|
29
|
+
*
|
|
30
|
+
* ESM only, Node builtins only.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import fs from 'node:fs'
|
|
34
|
+
import path from 'node:path'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The node interpreter inside the runtime that ships with the installation.
|
|
38
|
+
*
|
|
39
|
+
* It is relative to the directory of the application executable.
|
|
40
|
+
*/
|
|
41
|
+
export const BUNDLED_NODE_RELATIVE_PATH = Object.freeze([
|
|
42
|
+
'resources',
|
|
43
|
+
'runtime',
|
|
44
|
+
'primary-runtime',
|
|
45
|
+
'dependencies',
|
|
46
|
+
'node',
|
|
47
|
+
'bin',
|
|
48
|
+
'node.exe',
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Is this file name the name of a node interpreter?
|
|
53
|
+
*
|
|
54
|
+
* `node`, `node.exe`, `node22.exe` and `nodejs` are interpreters; a directory
|
|
55
|
+
* called `node_modules` is not.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} execPath
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
export function isNodeExecutable(execPath) {
|
|
61
|
+
const text = String(execPath ?? '').trim()
|
|
62
|
+
if (text === '') return false
|
|
63
|
+
const base = path.basename(text).toLowerCase()
|
|
64
|
+
if (!base.startsWith('node')) return false
|
|
65
|
+
return !base.includes('node_modules')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The directories of the PATH (Windows spells it `Path` as well).
|
|
70
|
+
*
|
|
71
|
+
* @param {object} env
|
|
72
|
+
* @returns {string[]}
|
|
73
|
+
*/
|
|
74
|
+
function pathDirectories(env) {
|
|
75
|
+
const raw = typeof env.PATH === 'string' && env.PATH !== ''
|
|
76
|
+
? env.PATH
|
|
77
|
+
: (typeof env.Path === 'string' ? env.Path : '')
|
|
78
|
+
if (raw === '') return []
|
|
79
|
+
return raw.split(path.delimiter).map((part) => part.trim()).filter((part) => part !== '')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Find a real node interpreter on this machine.
|
|
84
|
+
*
|
|
85
|
+
* @param {{
|
|
86
|
+
* execPath?: string,
|
|
87
|
+
* platform?: string,
|
|
88
|
+
* env?: object,
|
|
89
|
+
* existsSyncFn?: Function,
|
|
90
|
+
* }} [options]
|
|
91
|
+
* @returns {{
|
|
92
|
+
* ok: boolean,
|
|
93
|
+
* path: string,
|
|
94
|
+
* source: 'process'|'install-dir'|'path'|'',
|
|
95
|
+
* reason: string,
|
|
96
|
+
* tried: string[],
|
|
97
|
+
* }}
|
|
98
|
+
*/
|
|
99
|
+
export function resolveNodeInterpreter(options = {}) {
|
|
100
|
+
const platform = typeof options.platform === 'string' ? options.platform : process.platform
|
|
101
|
+
const execPath = String(options.execPath ?? '')
|
|
102
|
+
const env = options.env && typeof options.env === 'object' ? options.env : process.env
|
|
103
|
+
const exists = typeof options.existsSyncFn === 'function' ? options.existsSyncFn : fs.existsSync
|
|
104
|
+
const tried = []
|
|
105
|
+
|
|
106
|
+
// (1) The process IS node.
|
|
107
|
+
if (isNodeExecutable(execPath)) {
|
|
108
|
+
return { ok: true, path: execPath, source: 'process', reason: '', tried }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// (2) The runtime that ships with the installation.
|
|
112
|
+
if (execPath !== '') {
|
|
113
|
+
const directory = path.dirname(execPath)
|
|
114
|
+
const candidates = platform === 'win32'
|
|
115
|
+
? [path.join(directory, ...BUNDLED_NODE_RELATIVE_PATH)]
|
|
116
|
+
: []
|
|
117
|
+
for (const candidate of candidates) {
|
|
118
|
+
tried.push(candidate)
|
|
119
|
+
if (exists(candidate)) {
|
|
120
|
+
return { ok: true, path: candidate, source: 'install-dir', reason: '', tried }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// (3) The PATH.
|
|
126
|
+
const names = platform === 'win32' ? ['node.exe'] : ['node']
|
|
127
|
+
for (const directory of pathDirectories(env)) {
|
|
128
|
+
for (const name of names) {
|
|
129
|
+
const candidate = path.join(directory, name)
|
|
130
|
+
tried.push(candidate)
|
|
131
|
+
if (exists(candidate)) {
|
|
132
|
+
return { ok: true, path: candidate, source: 'path', reason: '', tried }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
path: '',
|
|
140
|
+
source: '',
|
|
141
|
+
reason: 'no node interpreter was found: the executable of this process is not node, the installation carries no bundled node, and the PATH has none either — the application executable is never used as a stand-in for node',
|
|
142
|
+
tried,
|
|
143
|
+
}
|
|
144
|
+
}
|