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
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
+ }
@@ -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
+ }