dsh-ensp 0.3.3 → 0.4.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.
@@ -1,227 +1,236 @@
1
- /**
2
- * Device availability checks.
3
- *
4
- * Some eNSP models are only stubs in the device catalogue: the entry exists, you
5
- * can drop it on the canvas and save the topology, but the vendor image behind it
6
- * is not installed. Placing one is not a soft failure — **the client opens the
7
- * topology, paints it, and then exits by itself**, so the launch looks successful
8
- * for a few seconds and everything downstream fails with a confusing
9
- * "console never answered".
10
- *
11
- * That is exactly what happens here with `USG6000V`: `res/items.xml` declares it
12
- * as `<executable isplugin="1" path="ngfw\ngfw_Plugin.dll"/>`, the plugin DLL is
13
- * present, and `plugin/ngfw/DataBase/` is empty — the `.vdi` the plugin's VirtualBox
14
- * template points at was never installed. `USG5500` is retired outright
15
- * ("eNSP don't maintained this device any more, just keep it.") and has no image
16
- * either.
17
- *
18
- * Rather than guess, this module reads the catalogue and checks the files each
19
- * plugin model actually needs, so a topology can be rejected *before* the client is
20
- * started.
21
- *
22
- * @module dsh-ensp/compat
23
- */
24
-
25
- import { existsSync, readFileSync } from 'node:fs'
26
- import path from 'node:path'
27
-
28
- /** Default install root; override with `DSH_ENSP_EXE`'s directory or the `root` option. */
29
- export const DEFAULT_ENSP_ROOT = 'E:\\Huawei\\eNSP'
30
-
31
- /**
32
- * Files a plugin device needs beyond its catalogue entry.
33
- *
34
- * `USG6000V`'s VirtualBox template (`plugin/ngfw/tools/ngfw/vfw_usg_for_vbox5.0.vbox`)
35
- * carries `<HardDisk ... location="../../DataBase/vfw_usg.vdi"/>`, which resolves to
36
- * `plugin/ngfw/DataBase/vfw_usg.vdi` relative to that template.
37
- */
38
- const PLUGIN_ASSETS = {
39
- USG6000V: [
40
- {
41
- rel: 'plugin/ngfw/DataBase/vfw_usg.vdi',
42
- why: 'NGFW 镜像未安装(eNSP 安装时需勾选 USG6000V 组件)',
43
- },
44
- ],
45
- }
46
-
47
- /** Models the vendor has withdrawn; usable only if an image happens to exist. */
48
- const RETIRED = {
49
- USG5500: 'eNSP 已停止维护 USG5500,安装包不再附带镜像,请改用 USG6000V',
50
- }
51
-
52
- /**
53
- * Static notes about devices that need an extra image, for documentation.
54
- *
55
- * Kept independent of the local installation so generated docs say the same thing
56
- * on every machine; `probeDevice` is the machine-specific check.
57
- */
58
- export const IMAGE_NOTES = {
59
- USG6000V:
60
- '需要 eNSP 安装时勾选 USG6000V 组件(NGFW 镜像,约 1 GB)。缺镜像时 eNSP 会打开拓扑后自行退出,' +
61
- '`ensp up` 会在启动前直接报错说明缺什么。',
62
- USG5500: 'eNSP 已停止维护该机型,安装包不再附带镜像,建议改用 USG6000V。',
63
- }
64
-
65
- /**
66
- * Device notes for a list of models, as `[model, note]` pairs.
67
- *
68
- * @param {string[]} models
69
- * @returns {Array<[string, string]>}
70
- */
71
- export function imageNotesFor(models) {
72
- return [...new Set(models)].filter((m) => IMAGE_NOTES[m]).map((m) => [m, IMAGE_NOTES[m]])
73
- }
74
-
75
- const cache = new Map()
76
-
77
- /**
78
- * Parse `res/items.xml` into `{ model: { plugin, pluginPath, needRegister } }`.
79
- *
80
- * @param {string} root eNSP install directory
81
- * @returns {Map<string, {plugin: boolean, pluginPath: string, needRegister: boolean}>}
82
- */
83
- function readCatalogue(root) {
84
- const file = path.join(root, 'res', 'items.xml')
85
- if (cache.has(file)) return cache.get(file)
86
-
87
- const table = new Map()
88
- let xml = ''
89
- try {
90
- xml = readFileSync(file, 'utf8')
91
- } catch {
92
- cache.set(file, table)
93
- return table
94
- }
95
-
96
- for (const m of xml.matchAll(/<model name="([^"]+)">([\s\S]*?)<\/model>/g)) {
97
- const body = m[2]
98
- const exe = /<executable isplugin="(\d)" path="([^"]*)"/.exec(body)
99
- const reg = /<needRegister>(\d+)<\/needRegister>/.exec(body)
100
- table.set(m[1], {
101
- plugin: exe ? exe[1] === '1' : false,
102
- pluginPath: exe ? exe[2] : '',
103
- needRegister: reg ? reg[1] === '1' : false,
104
- })
105
- }
106
- cache.set(file, table)
107
- return table
108
- }
109
-
110
- /** Derive the install root from an explicit executable path. */
111
- export function rootFromExe(exe) {
112
- return exe ? path.dirname(exe) : DEFAULT_ENSP_ROOT
113
- }
114
-
115
- /**
116
- * Can this model actually be started on this machine?
117
- *
118
- * @param {string} model
119
- * @param {{root?: string}} [options]
120
- * @returns {{model: string, available: boolean, reason?: string}}
121
- */
122
- export function probeDevice(model, options = {}) {
123
- const root = options.root ?? DEFAULT_ENSP_ROOT
124
- const entry = readCatalogue(root).get(model)
125
-
126
- if (entry?.plugin && entry.pluginPath) {
127
- const dll = path.join(root, 'plugin', entry.pluginPath.split('\\').join('/'))
128
- if (!existsSync(dll)) {
129
- return { model, available: false, reason: `插件缺失:${path.relative(root, dll)}` }
130
- }
131
- }
132
-
133
- // Checked before the catalogue entry on purpose: the assets a model needs are a
134
- // property of the model, not of the catalogue file. A machine with no eNSP
135
- // installed at all still cannot start a USG6000V, and answering "available"
136
- // because items.xml was unreadable would be the exact false negative this module
137
- // exists to prevent.
138
- for (const asset of PLUGIN_ASSETS[model] ?? []) {
139
- if (!existsSync(path.join(root, asset.rel))) {
140
- return { model, available: false, reason: asset.why }
141
- }
142
- }
143
-
144
- if (RETIRED[model] && !hasAnyImage(root, model)) {
145
- return { model, available: false, reason: RETIRED[model] }
146
- }
147
-
148
- // Unknown models are the caller's problem (a topology naming them is a different
149
- // error), so stay permissive rather than blocking every lab on a stale catalogue.
150
- return { model, available: true }
151
- }
152
-
153
- /**
154
- * Does any VirtualBox image under the eNSP tree look like it belongs to `model`?
155
- *
156
- * Used only for retired models, where the catalogue says nothing about the image.
157
- *
158
- * @param {string} root
159
- * @param {string} model
160
- */
161
- function hasAnyImage(root, model) {
162
- const stem = model.toLowerCase()
163
- const candidates = [
164
- path.join(root, 'vboxserver'),
165
- path.join(root, 'plugin'),
166
- ]
167
- for (const dir of candidates) {
168
- if (!existsSync(dir)) continue
169
- if (existsSync(path.join(dir, stem))) return true
170
- }
171
- return false
172
- }
173
-
174
- /**
175
- * Check every device in a parsed topology.
176
- *
177
- * @param {{devices: Array<{name: string, model: string}>}} topo
178
- * @param {{root?: string}} [options]
179
- * @returns {{ok: boolean, blocked: Array<{name: string, model: string, reason: string}>, checked: string[]}}
180
- */
181
- export function checkTopologySupport(topo, options = {}) {
182
- const blocked = []
183
- for (const device of topo.devices ?? []) {
184
- const result = probeDevice(device.model, options)
185
- if (!result.available) {
186
- blocked.push({ name: device.name, model: device.model, reason: result.reason })
187
- }
188
- }
189
- return {
190
- ok: blocked.length === 0,
191
- blocked,
192
- checked: [...new Set((topo.devices ?? []).map((d) => d.model))].sort(),
193
- }
194
- }
195
-
196
- /**
197
- * Build the error text for a blocked topology.
198
- *
199
- * Names the device, the model and what is missing, then says what to do. Repeated
200
- * models are grouped so a five-firewall lab prints one explanation, not five.
201
- *
202
- * @param {{blocked: Array<{name: string, model: string, reason: string}>}} report
203
- * @returns {string}
204
- */
205
- export function describeBlocked(report) {
206
- const byModel = new Map()
207
- for (const item of report.blocked) {
208
- const group = byModel.get(item.model) ?? { reason: item.reason, names: [] }
209
- group.names.push(item.name)
210
- byModel.set(item.model, group)
211
- }
212
- const lines = [...byModel].map(
213
- ([model, { reason, names }]) => ` ${model} (${names.join(', ')}): ${reason}`,
214
- )
215
- return (
216
- 'topology uses devices this eNSP installation cannot start:\n' +
217
- lines.join('\n') +
218
- '\n\nThis is not a bug in the topology: eNSP opens it, renders it, then quits. ' +
219
- 'Install the missing device image in eNSP (工具 → 注册设备, or reinstall eNSP with ' +
220
- 'the device component selected), or remove those devices from the topology.'
221
- )
222
- }
223
-
224
- /** Forget cached catalogue reads (tests, and after installing a device image). */
225
- export function clearCatalogueCache() {
226
- cache.clear()
227
- }
1
+ /**
2
+ * Device availability checks.
3
+ *
4
+ * Some eNSP models are only stubs in the device catalogue: the entry exists, you
5
+ * can drop it on the canvas and save the topology, but the vendor image behind it
6
+ * is not installed. Placing one is not a soft failure — **the client opens the
7
+ * topology, paints it, and then exits by itself**, so the launch looks successful
8
+ * for a few seconds and everything downstream fails with a confusing
9
+ * "console never answered".
10
+ *
11
+ * That is exactly what happens here with `USG6000V`: `res/items.xml` declares it
12
+ * as `<executable isplugin="1" path="ngfw\ngfw_Plugin.dll"/>`, the plugin DLL is
13
+ * present, and `plugin/ngfw/DataBase/` is empty — the `.vdi` the plugin's VirtualBox
14
+ * template points at was never installed. `USG5500` is retired outright
15
+ * ("eNSP don't maintained this device any more, just keep it.") and has no image
16
+ * either.
17
+ *
18
+ * Rather than guess, this module reads the catalogue and checks the files each
19
+ * plugin model actually needs, so a topology can be rejected *before* the client is
20
+ * started.
21
+ *
22
+ * @module dsh-ensp/compat
23
+ */
24
+
25
+ import { existsSync, readFileSync } from 'node:fs'
26
+ import path from 'node:path'
27
+
28
+ import { resolveEnspRoot } from './paths.mjs'
29
+
30
+ /** Default install root; override with `DSH_ENSP_EXE`'s directory or the `root` option. */
31
+ export const DEFAULT_ENSP_ROOT = 'E:\\Huawei\\eNSP'
32
+
33
+ /**
34
+ * Files a plugin device needs beyond its catalogue entry.
35
+ *
36
+ * `USG6000V`'s VirtualBox template (`plugin/ngfw/tools/ngfw/vfw_usg_for_vbox5.0.vbox`)
37
+ * carries `<HardDisk ... location="../../DataBase/vfw_usg.vdi"/>`, which resolves to
38
+ * `plugin/ngfw/DataBase/vfw_usg.vdi` relative to that template.
39
+ */
40
+ const PLUGIN_ASSETS = {
41
+ USG6000V: [
42
+ {
43
+ rel: 'plugin/ngfw/DataBase/vfw_usg.vdi',
44
+ why: 'NGFW 镜像未安装(eNSP 安装时需勾选 USG6000V 组件)',
45
+ },
46
+ ],
47
+ }
48
+
49
+ /** Models the vendor has withdrawn; usable only if an image happens to exist. */
50
+ const RETIRED = {
51
+ USG5500: 'eNSP 已停止维护 USG5500,安装包不再附带镜像,请改用 USG6000V',
52
+ }
53
+
54
+ /**
55
+ * Static notes about devices that need an extra image, for documentation.
56
+ *
57
+ * Kept independent of the local installation so generated docs say the same thing
58
+ * on every machine; `probeDevice` is the machine-specific check.
59
+ */
60
+ export const IMAGE_NOTES = {
61
+ USG6000V:
62
+ '需要 eNSP 安装时勾选 USG6000V 组件(NGFW 镜像,约 1 GB)。缺镜像时 eNSP 会打开拓扑后自行退出,' +
63
+ '`ensp up` 会在启动前直接报错说明缺什么。',
64
+ USG5500: 'eNSP 已停止维护该机型,安装包不再附带镜像,建议改用 USG6000V。',
65
+ }
66
+
67
+ /**
68
+ * Device notes for a list of models, as `[model, note]` pairs.
69
+ *
70
+ * @param {string[]} models
71
+ * @returns {Array<[string, string]>}
72
+ */
73
+ export function imageNotesFor(models) {
74
+ return [...new Set(models)].filter((m) => IMAGE_NOTES[m]).map((m) => [m, IMAGE_NOTES[m]])
75
+ }
76
+
77
+ const cache = new Map()
78
+
79
+ /**
80
+ * Parse `res/items.xml` into `{ model: { plugin, pluginPath, needRegister } }`.
81
+ *
82
+ * @param {string} root eNSP install directory
83
+ * @returns {Map<string, {plugin: boolean, pluginPath: string, needRegister: boolean}>}
84
+ */
85
+ function readCatalogue(root) {
86
+ const file = path.join(root, 'res', 'items.xml')
87
+ if (cache.has(file)) return cache.get(file)
88
+
89
+ const table = new Map()
90
+ let xml = ''
91
+ try {
92
+ xml = readFileSync(file, 'utf8')
93
+ } catch {
94
+ cache.set(file, table)
95
+ return table
96
+ }
97
+
98
+ for (const m of xml.matchAll(/<model name="([^"]+)">([\s\S]*?)<\/model>/g)) {
99
+ const body = m[2]
100
+ const exe = /<executable isplugin="(\d)" path="([^"]*)"/.exec(body)
101
+ const reg = /<needRegister>(\d+)<\/needRegister>/.exec(body)
102
+ table.set(m[1], {
103
+ plugin: exe ? exe[1] === '1' : false,
104
+ pluginPath: exe ? exe[2] : '',
105
+ needRegister: reg ? reg[1] === '1' : false,
106
+ })
107
+ }
108
+ cache.set(file, table)
109
+ return table
110
+ }
111
+
112
+ /**
113
+ * Derive the install root from an explicit executable path.
114
+ *
115
+ * Falls back to the resolved install root, then to the historical default, so a
116
+ * caller that has no path at hand still gets a usable directory rather than
117
+ * silently reading the wrong catalogue.
118
+ */
119
+ export function rootFromExe(exe) {
120
+ if (exe) return path.dirname(exe)
121
+ return resolveEnspRoot() ?? DEFAULT_ENSP_ROOT
122
+ }
123
+
124
+ /**
125
+ * Can this model actually be started on this machine?
126
+ *
127
+ * @param {string} model
128
+ * @param {{root?: string}} [options]
129
+ * @returns {{model: string, available: boolean, reason?: string}}
130
+ */
131
+ export function probeDevice(model, options = {}) {
132
+ const root = options.root ?? DEFAULT_ENSP_ROOT
133
+ const entry = readCatalogue(root).get(model)
134
+
135
+ if (entry?.plugin && entry.pluginPath) {
136
+ const dll = path.join(root, 'plugin', entry.pluginPath.split('\\').join('/'))
137
+ if (!existsSync(dll)) {
138
+ return { model, available: false, reason: `插件缺失:${path.relative(root, dll)}` }
139
+ }
140
+ }
141
+
142
+ // Checked before the catalogue entry on purpose: the assets a model needs are a
143
+ // property of the model, not of the catalogue file. A machine with no eNSP
144
+ // installed at all still cannot start a USG6000V, and answering "available"
145
+ // because items.xml was unreadable would be the exact false negative this module
146
+ // exists to prevent.
147
+ for (const asset of PLUGIN_ASSETS[model] ?? []) {
148
+ if (!existsSync(path.join(root, asset.rel))) {
149
+ return { model, available: false, reason: asset.why }
150
+ }
151
+ }
152
+
153
+ if (RETIRED[model] && !hasAnyImage(root, model)) {
154
+ return { model, available: false, reason: RETIRED[model] }
155
+ }
156
+
157
+ // Unknown models are the caller's problem (a topology naming them is a different
158
+ // error), so stay permissive rather than blocking every lab on a stale catalogue.
159
+ return { model, available: true }
160
+ }
161
+
162
+ /**
163
+ * Does any VirtualBox image under the eNSP tree look like it belongs to `model`?
164
+ *
165
+ * Used only for retired models, where the catalogue says nothing about the image.
166
+ *
167
+ * @param {string} root
168
+ * @param {string} model
169
+ */
170
+ function hasAnyImage(root, model) {
171
+ const stem = model.toLowerCase()
172
+ const candidates = [
173
+ path.join(root, 'vboxserver'),
174
+ path.join(root, 'plugin'),
175
+ ]
176
+ for (const dir of candidates) {
177
+ if (!existsSync(dir)) continue
178
+ if (existsSync(path.join(dir, stem))) return true
179
+ }
180
+ return false
181
+ }
182
+
183
+ /**
184
+ * Check every device in a parsed topology.
185
+ *
186
+ * @param {{devices: Array<{name: string, model: string}>}} topo
187
+ * @param {{root?: string}} [options]
188
+ * @returns {{ok: boolean, blocked: Array<{name: string, model: string, reason: string}>, checked: string[]}}
189
+ */
190
+ export function checkTopologySupport(topo, options = {}) {
191
+ const blocked = []
192
+ for (const device of topo.devices ?? []) {
193
+ const result = probeDevice(device.model, options)
194
+ if (!result.available) {
195
+ blocked.push({ name: device.name, model: device.model, reason: result.reason })
196
+ }
197
+ }
198
+ return {
199
+ ok: blocked.length === 0,
200
+ blocked,
201
+ checked: [...new Set((topo.devices ?? []).map((d) => d.model))].sort(),
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Build the error text for a blocked topology.
207
+ *
208
+ * Names the device, the model and what is missing, then says what to do. Repeated
209
+ * models are grouped so a five-firewall lab prints one explanation, not five.
210
+ *
211
+ * @param {{blocked: Array<{name: string, model: string, reason: string}>}} report
212
+ * @returns {string}
213
+ */
214
+ export function describeBlocked(report) {
215
+ const byModel = new Map()
216
+ for (const item of report.blocked) {
217
+ const group = byModel.get(item.model) ?? { reason: item.reason, names: [] }
218
+ group.names.push(item.name)
219
+ byModel.set(item.model, group)
220
+ }
221
+ const lines = [...byModel].map(
222
+ ([model, { reason, names }]) => ` ${model} (${names.join(', ')}): ${reason}`,
223
+ )
224
+ return (
225
+ 'topology uses devices this eNSP installation cannot start:\n' +
226
+ lines.join('\n') +
227
+ '\n\nThis is not a bug in the topology: eNSP opens it, renders it, then quits. ' +
228
+ 'Install the missing device image in eNSP (工具 → 注册设备, or reinstall eNSP with ' +
229
+ 'the device component selected), or remove those devices from the topology.'
230
+ )
231
+ }
232
+
233
+ /** Forget cached catalogue reads (tests, and after installing a device image). */
234
+ export function clearCatalogueCache() {
235
+ cache.clear()
236
+ }
@@ -195,6 +195,19 @@ export class ConsoleSession {
195
195
  return this.#prompt
196
196
  }
197
197
 
198
+ /**
199
+ * The hostname learned from the user-view prompt.
200
+ *
201
+ * Internal callers must pass it to `viewOf`. Without it a hostname containing a
202
+ * dash (`SW2-ACCESS`) makes every system-view prompt look like a sub-view, and
203
+ * the config engine ends up retrying quit/system-view instead of working.
204
+ *
205
+ * @returns {string|null}
206
+ */
207
+ get hostname() {
208
+ return this.#hostname
209
+ }
210
+
198
211
  /** @returns {'user'|'system'|'sub'|null} */
199
212
  get view() {
200
213
  return viewOf(this.#prompt, this.#hostname)
@@ -240,16 +253,17 @@ export class ConsoleSession {
240
253
  * @param {number} settleMs
241
254
  * @returns {Promise<string>} the transcript produced since the buffer was last cleared
242
255
  */
243
- async #readToPrompt(timeoutMs, settleMs = 60) {
256
+ async #readToPrompt(timeoutMs, settleMs = 60, echo = null) {
244
257
  const started = Date.now()
245
258
  while (Date.now() - started < timeoutMs) {
246
259
  if (this.#closed) break
247
260
  const text = clean(this.#buf)
248
261
  // A confirmation question wins over a stale prompt: answer it explicitly.
249
262
  if (CONFIRM_RE.test(text.split('\n').slice(-3).join('\n'))) break
263
+ const sawEcho = !echo || text.includes(echo)
250
264
  // A pager stop is a terminal state too: nothing more will arrive until we
251
265
  // send a key, so waiting for a prompt here would burn the whole timeout.
252
- if (PROMPT_RE.test(text) || PAGER_RE.test(text.trimEnd())) {
266
+ if (sawEcho && (PROMPT_RE.test(text) || PAGER_RE.test(text.trimEnd()))) {
253
267
  const before = this.#buf.length
254
268
  await this.#waitUntil(() => this.#buf.length !== before || this.#closed, settleMs)
255
269
  if (this.#buf.length === before) break
@@ -257,14 +271,23 @@ export class ConsoleSession {
257
271
  // Poll the buffer rather than sleeping a fixed slice: most commands reply
258
272
  // in a few milliseconds, and waiting 200 ms per line dominates the runtime
259
273
  // of a 300-line config.
260
- await this.#waitUntil(() => PROMPT_RE.test(clean(this.#buf)) || PAGER_RE.test(clean(this.#buf).trimEnd()) || this.#closed, 25)
274
+ await this.#waitUntil(() => {
275
+ if (this.#closed) return true
276
+ const now = clean(this.#buf)
277
+ if (echo && !now.includes(echo)) return false
278
+ return PROMPT_RE.test(now) || PAGER_RE.test(now.trimEnd())
279
+ }, 25)
261
280
  }
262
281
  }
263
282
  const text = clean(this.#buf)
264
283
  this.#buf = ''
265
- const found = text.match(/[<\[][\w~\-./:]*[>\]]\s*$/m) || text.trimEnd().match(/[<\[][\w~\-./:]*[>\]]$/)
266
- if (found) {
267
- this.#prompt = found[0].trim()
284
+ // The *last* prompt in the transcript is the current one. Matching with an
285
+ // `m` anchor and taking the first hit returns a prompt from the middle of the
286
+ // output, which is how a reply that still carried the previous prompt made the
287
+ // session believe it stood one view higher than it did.
288
+ const promptAt = [...text.matchAll(/[<\[][\w~\-./:]*[>\]]/g)].pop()
289
+ if (promptAt) {
290
+ this.#prompt = promptAt[0].trim()
268
291
  // Learn the hostname the first time we see the user-view prompt; it is the
269
292
  // only reliable anchor for telling `[R1-TEST]` (system) from `[R1-TEST-GE0/0/0]`.
270
293
  this.#hostname ??= hostnameOf(this.#prompt)
@@ -301,16 +324,11 @@ export class ConsoleSession {
301
324
 
302
325
  this.#buf = ''
303
326
  this.#line(command)
304
- let output = await this.#readToPrompt(timeoutMs, entersView ? 200 : 60)
305
-
306
- if (entersView) {
307
- // If the echo is missing the device had not finished switching views yet;
308
- // give it one more read window rather than moving on.
309
- const echoed = output.includes(command.split(/\s+/)[0])
310
- if (!echoed) {
311
- output += await this.#readToPrompt(timeoutMs, 100)
312
- }
313
- }
327
+ // VRP echoes the command before it answers. Requiring that echo makes the
328
+ // prompt trustworthy: otherwise a prompt left over from the previous command
329
+ // can satisfy the match and the caller then acts on the wrong view.
330
+ const echo = command.split(/\s+/)[0]
331
+ let output = await this.#readToPrompt(timeoutMs, entersView ? 200 : 60, echo)
314
332
 
315
333
  // Answer at most one confirmation round; `save` and `reset` both use one.
316
334
  if (CONFIRM_RE.test(output) && !PROMPT_RE.test(output.trimEnd())) {
@@ -21,11 +21,9 @@ import path from 'node:path'
21
21
  import { checkTopologySupport, probeDevice } from './compat.mjs'
22
22
  import { DEFAULT_ANCHORS, calibrateAnchors } from './calibrate.mjs'
23
23
  import { enspPid, locateWindow } from './launcher.mjs'
24
+ import { probePaths, resolveEnspRoot } from './paths.mjs'
24
25
 
25
- export const DEFAULT_ENSP_EXE = 'E:\\Huawei\\eNSP\\eNSP_Client.exe'
26
- export const DEFAULT_VBOX = 'E:\\VirtualBox\\VBoxManage.exe'
27
-
28
- const MIN_FREE_MEMORY_MB = 1024
26
+ export const MIN_FREE_MEMORY_MB = 1024
29
27
 
30
28
  /** PowerShell helper that answers "does synthetic input reach this session?". */
31
29
  const INPUT_PROBE = String.raw`
@@ -172,33 +170,39 @@ function check(id, status, detail, fix) {
172
170
  * @param {boolean} [options.deep] also run the input probe (slow: ~2 s)
173
171
  */
174
172
  export function doctor(options = {}) {
175
- const exe = options.exe || DEFAULT_ENSP_EXE
176
- const vbox = options.vbox || DEFAULT_VBOX
177
- const root = options.root || path.dirname(exe)
173
+ const found = probePaths({ exe: options.exe, vbox: options.vbox })
174
+ const exe = found.ensp.path
175
+ const vbox = found.vbox.path
176
+ const root = options.root || resolveEnspRoot({ exe })
178
177
  const deep = options.deep !== false
179
178
  const checks = []
180
179
 
181
180
  // 1. eNSP client present
182
- const exeOk = fs.existsSync(exe)
181
+ const exeOk = Boolean(exe) && fs.existsSync(exe)
183
182
  checks.push(
184
183
  check(
185
184
  'ensp-installed',
186
185
  exeOk ? 'pass' : 'fail',
187
- exeOk ? exe : `not found: ${exe}`,
186
+ exeOk ? exe : 'not found in any configured or default location',
188
187
  exeOk
189
188
  ? undefined
190
- : 'Install eNSP, or pass --exe with the real path to eNSP_Client.exe.',
189
+ : 'Install eNSP, or pass --exe with the full path to eNSP_Client.exe, '
190
+ + 'or set DSH_ENSP_EXE. Both the default install root and the Windows '
191
+ + 'install registry were checked.',
191
192
  ),
192
193
  )
193
194
 
194
195
  // 2. VirtualBox present (eNSP cannot boot a single device without it)
195
- const vboxOk = fs.existsSync(vbox)
196
+ const vboxOk = Boolean(vbox) && fs.existsSync(vbox)
196
197
  checks.push(
197
198
  check(
198
199
  'virtualbox',
199
200
  vboxOk ? 'pass' : 'fail',
200
- vboxOk ? vbox : `not found: ${vbox}`,
201
- vboxOk ? undefined : 'Install VirtualBox 5.2.x, which ships with eNSP.',
201
+ vboxOk ? vbox : 'not found in PATH or any default location',
202
+ vboxOk
203
+ ? undefined
204
+ : 'Install VirtualBox 5.2.x, which ships with eNSP, or pass --vbox with '
205
+ + 'the full path to VBoxManage.exe, or set DSH_VBOX.',
202
206
  ),
203
207
  )
204
208
 
@@ -366,4 +370,3 @@ export function modelsOf(topology) {
366
370
  }
367
371
 
368
372
  /** Free-memory constant, exported so tests can assert against it. */
369
- export { MIN_FREE_MEMORY_MB }
@@ -69,8 +69,6 @@ export {
69
69
  freeMemoryMb,
70
70
  busyConsolePorts,
71
71
  modelsOf,
72
- DEFAULT_ENSP_EXE,
73
- DEFAULT_VBOX,
74
72
  MIN_FREE_MEMORY_MB,
75
73
  } from './doctor.mjs'
76
74
 
@@ -124,3 +122,12 @@ export {
124
122
  renderScenario,
125
123
  searchScenarios,
126
124
  } from './presets.mjs'
125
+
126
+ export {
127
+ resolveEnspExe,
128
+ resolveVBoxManage,
129
+ resolveEnspRoot,
130
+ probePaths,
131
+ enspCandidates,
132
+ vboxCandidates,
133
+ } from './paths.mjs'