dsh-ensp 0.6.0 → 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.
@@ -22,28 +22,27 @@
22
22
  * @module dsh-ensp/compat
23
23
  */
24
24
 
25
- import { existsSync, readFileSync } from 'node:fs'
25
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
26
26
  import path from 'node:path'
27
27
 
28
28
  import { resolveEnspRoot } from './paths.mjs'
29
29
 
30
- /** Default install root; override with `DSH_ENSP_EXE`'s directory or the `root` option. */
30
+ /** Last-resort install root, used only when nothing on this machine can be found. */
31
31
  export const DEFAULT_ENSP_ROOT = 'E:\\Huawei\\eNSP'
32
32
 
33
33
  /**
34
- * Files a plugin device needs beyond its catalogue entry.
34
+ * Human wording for a plugin model whose image is missing.
35
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.
36
+ * The *paths* are never taken from here: they are read out of the VirtualBox
37
+ * templates that ship with the installation, so a machine that keeps eNSP
38
+ * somewhere else still gets the right answer. The relative path below is only a
39
+ * name to print in the message, and the reason text is what the user reads.
39
40
  */
40
41
  const PLUGIN_ASSETS = {
41
- USG6000V: [
42
- {
43
- rel: 'plugin/ngfw/DataBase/vfw_usg.vdi',
44
- why: 'NGFW 镜像未安装(eNSP 安装时需勾选 USG6000V 组件)',
45
- },
46
- ],
42
+ USG6000V: {
43
+ rel: 'plugin/ngfw/DataBase/vfw_usg.vdi',
44
+ why: 'NGFW 镜像未安装(eNSP 安装时需勾选 USG6000V 组件)',
45
+ },
47
46
  }
48
47
 
49
48
  /** Models the vendor has withdrawn; usable only if an image happens to exist. */
@@ -74,6 +73,88 @@ export function imageNotesFor(models) {
74
73
  return [...new Set(models)].filter((m) => IMAGE_NOTES[m]).map((m) => [m, IMAGE_NOTES[m]])
75
74
  }
76
75
 
76
+ /** `<HardDisk ... location="...">` inside a VirtualBox machine template. */
77
+ const MEDIA_TAG = /<HardDisk\b[^>]*\blocation="([^"]+)"/g
78
+
79
+ const TEMPLATE_EXT = new Set(['.vbox', '.xml'])
80
+
81
+ const mediaCache = new Map()
82
+
83
+ /**
84
+ * Every file a plugin model's VirtualBox template points at, as absolute paths.
85
+ *
86
+ * The plugin models (NE40E, NE5000E, NE9000, CX, CE6800, CE12800, USG6000V) each
87
+ * ship a VirtualBox machine template whose `<HardDisk location="...">` is relative
88
+ * to that template. The disk itself lives in a `Database` directory next to the
89
+ * plugin, but the exact spelling (`Database` vs `DataBase`) and the number of `..`
90
+ * differ per plugin, so the path is read out of the template every time instead of
91
+ * being reconstructed. A machine that installed eNSP somewhere else therefore gets
92
+ * the same answer as this one.
93
+ *
94
+ * Templates under a `backup` directory are ignored: they are stale copies kept by
95
+ * the vendor, and one of them (`vfw_usg_for_vbox4.0`) points at a layout no current
96
+ * installation has.
97
+ *
98
+ * @param {string} model
99
+ * @param {{root?: string}} [options]
100
+ * @returns {string[]} absolute media paths, empty when the model has no template
101
+ */
102
+ export function pluginMediaFiles(model, options = {}) {
103
+ const root = options.root ?? resolveEnspRoot() ?? DEFAULT_ENSP_ROOT
104
+ const entry = readCatalogue(root).get(model)
105
+ if (!entry?.plugin || !entry.pluginPath) return []
106
+
107
+ const key = `${root}\u0000${entry.pluginPath}`
108
+ if (mediaCache.has(key)) return mediaCache.get(key)
109
+
110
+ const dll = path.join(root, 'plugin', entry.pluginPath.split('\\').join('/'))
111
+ const found = []
112
+ for (const template of templatesUnder(path.dirname(dll))) {
113
+ let xml = ''
114
+ try {
115
+ xml = readFileSync(template, 'utf8')
116
+ } catch {
117
+ continue
118
+ }
119
+ for (const m of xml.matchAll(MEDIA_TAG)) {
120
+ found.push(path.resolve(path.dirname(template), m[1]))
121
+ }
122
+ }
123
+
124
+ const unique = [...new Set(found)]
125
+ mediaCache.set(key, unique)
126
+ return unique
127
+ }
128
+
129
+ /**
130
+ * Collect `.vbox` / `.xml` templates below `dir`, skipping vendor backups.
131
+ *
132
+ * @param {string} dir
133
+ * @returns {string[]}
134
+ */
135
+ function templatesUnder(dir) {
136
+ const out = []
137
+ const walk = (current) => {
138
+ let entries = []
139
+ try {
140
+ entries = readdirSync(current, { withFileTypes: true })
141
+ } catch {
142
+ return
143
+ }
144
+ for (const entry of entries) {
145
+ const full = path.join(current, entry.name)
146
+ if (entry.isDirectory()) {
147
+ if (entry.name.toLowerCase() === 'backup') continue
148
+ walk(full)
149
+ } else if (TEMPLATE_EXT.has(path.extname(entry.name).toLowerCase())) {
150
+ out.push(full)
151
+ }
152
+ }
153
+ }
154
+ walk(dir)
155
+ return out
156
+ }
157
+
77
158
  const cache = new Map()
78
159
 
79
160
  /**
@@ -124,12 +205,17 @@ export function rootFromExe(exe) {
124
205
  /**
125
206
  * Can this model actually be started on this machine?
126
207
  *
208
+ * `root` defaults to the installation found on this machine, not to a fixed
209
+ * path. A hardcoded `E:\Huawei\eNSP` made every check on a machine with a
210
+ * normal install read the wrong catalogue and look for device images that
211
+ * were never there, so a correctly installed USG6000V was reported missing.
212
+ *
127
213
  * @param {string} model
128
214
  * @param {{root?: string}} [options]
129
215
  * @returns {{model: string, available: boolean, reason?: string}}
130
216
  */
131
217
  export function probeDevice(model, options = {}) {
132
- const root = options.root ?? DEFAULT_ENSP_ROOT
218
+ const root = options.root ?? resolveEnspRoot() ?? DEFAULT_ENSP_ROOT
133
219
  const entry = readCatalogue(root).get(model)
134
220
 
135
221
  if (entry?.plugin && entry.pluginPath) {
@@ -144,9 +230,28 @@ export function probeDevice(model, options = {}) {
144
230
  // installed at all still cannot start a USG6000V, and answering "available"
145
231
  // because items.xml was unreadable would be the exact false negative this module
146
232
  // 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 }
233
+ const note = PLUGIN_ASSETS[model]
234
+ if (note) {
235
+ // Fall back to the documented relative path when no template survives, so an
236
+ // installation whose template was deleted but whose image is in place still
237
+ // starts instead of being blocked on a missing file that is right there.
238
+ const media = pluginMediaFiles(model, { root })
239
+ const candidates = media.length ? media : [path.join(root, note.rel)]
240
+ if (!candidates.some((file) => existsSync(file))) {
241
+ return { model, available: false, reason: note.why, missing: note.rel }
242
+ }
243
+ } else if (entry?.plugin) {
244
+ // Every other plugin model is judged the same way, from its own template. Only
245
+ // the wording differs, because there is no hand-written sentence for it.
246
+ const media = pluginMediaFiles(model, { root })
247
+ if (media.length && !media.some((file) => existsSync(file))) {
248
+ const named = media.map((file) => path.relative(root, file)).join(', ')
249
+ return {
250
+ model,
251
+ available: false,
252
+ reason: `设备镜像未安装:缺少 ${named}`,
253
+ missing: named,
254
+ }
150
255
  }
151
256
  }
152
257
 
@@ -230,7 +335,134 @@ export function describeBlocked(report) {
230
335
  )
231
336
  }
232
337
 
233
- /** Forget cached catalogue reads (tests, and after installing a device image). */
338
+ /** Forget cached catalogue and template reads (tests, and after installing a device image). */
234
339
  export function clearCatalogueCache() {
235
340
  cache.clear()
341
+ mediaCache.clear()
342
+ }
343
+
344
+ /**
345
+ * Every plugin model this installation knows about, sorted.
346
+ *
347
+ * Read out of `res/items.xml`, so it grows and shrinks with the installation
348
+ * instead of being a list maintained here.
349
+ *
350
+ * @param {{root?: string}} [options]
351
+ * @returns {string[]}
352
+ */
353
+ export function pluginModels(options = {}) {
354
+ const root = options.root ?? resolveEnspRoot() ?? DEFAULT_ENSP_ROOT
355
+ return [...readCatalogue(root)]
356
+ .filter(([, entry]) => entry.plugin && entry.pluginPath)
357
+ .map(([model]) => model)
358
+ .sort()
359
+ }
360
+
361
+ /**
362
+ * What a plugin model needs on disk, and what of that is actually there.
363
+ *
364
+ * `media` is derived from the model's own VirtualBox template, so the answer holds
365
+ * on any machine regardless of where eNSP is installed. `target` is where an image
366
+ * has to go if one is missing: the first path the template names.
367
+ *
368
+ * @param {string} model
369
+ * @param {{root?: string}} [options]
370
+ * @returns {{model: string, plugin: boolean, target: string|null,
371
+ * media: Array<{path: string, present: boolean, relative: string}>, ready: boolean}}
372
+ */
373
+ export function pluginMediaPlan(model, options = {}) {
374
+ const root = options.root ?? resolveEnspRoot() ?? DEFAULT_ENSP_ROOT
375
+ const entry = readCatalogue(root).get(model)
376
+ const template = pluginMediaFiles(model, { root })
377
+ const media = template.map((file) => ({
378
+ path: file,
379
+ relative: path.relative(root, file),
380
+ present: existsSync(file),
381
+ }))
382
+ return {
383
+ model,
384
+ plugin: Boolean(entry?.plugin),
385
+ target: media.length ? media[0].path : null,
386
+ media,
387
+ ready: media.some((item) => item.present),
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Put a device image where this machine's eNSP expects it.
393
+ *
394
+ * The destination is not passed in: it is the path the model's VirtualBox template
395
+ * already points at, which is the only place the emulator will look. Copying
396
+ * elsewhere and hoping is how people end up with a firewall that opens a topology
397
+ * and then quits. The plugin's `system.ini` is updated as well when it has an
398
+ * `ImgFile` line, because that is what the plugin reads for single-image models.
399
+ *
400
+ * @param {string} model
401
+ * @param {string} source image file to install
402
+ * @param {{root?: string, overwrite?: boolean}} [options]
403
+ * @returns {{model: string, target: string, bytes: number, iniUpdated: boolean}}
404
+ */
405
+ export function installPluginMedia(model, source, options = {}) {
406
+ const root = options.root ?? resolveEnspRoot() ?? DEFAULT_ENSP_ROOT
407
+ if (!existsSync(source)) throw new Error(`the image file does not exist: ${source}`)
408
+
409
+ const plan = pluginMediaPlan(model, { root })
410
+ if (!plan.plugin) throw new Error(`${model} is not a plugin model in this eNSP installation`)
411
+ if (!plan.target) {
412
+ throw new Error(
413
+ `${model} has no VirtualBox template under ${root}, so there is nowhere to put an image. ` +
414
+ 'The installation looks incomplete; reinstall eNSP.',
415
+ )
416
+ }
417
+ if (plan.ready && !options.overwrite) {
418
+ return { model, target: plan.target, bytes: 0, iniUpdated: false }
419
+ }
420
+
421
+ mkdirSync(path.dirname(plan.target), { recursive: true })
422
+ copyFileSync(source, plan.target)
423
+
424
+ // `<plugin>/System/system.ini` carries `ImgFile =` for the plugins that boot a
425
+ // single image, and an empty value there is why a correctly placed image can
426
+ // still leave the device unusable.
427
+ const iniUpdated = updateImgFile(path.join(root, 'plugin', pluginDir(plan.target, root) ?? '', 'System', 'system.ini'), path.basename(plan.target))
428
+
429
+ mediaCache.clear()
430
+ cache.clear()
431
+ return { model, target: plan.target, bytes: statSync(plan.target).size, iniUpdated }
432
+ }
433
+
434
+ /** The plugin directory an image sits under (`plugin/<name>/...`), or null. */
435
+ function pluginDir(file, root) {
436
+ const relative = path.relative(path.join(root, 'plugin'), file)
437
+ if (relative.startsWith('..')) return null
438
+ return relative.split(path.sep)[0]
439
+ }
440
+
441
+ /**
442
+ * Point `ImgFile` in a plugin `system.ini` at a file that now exists.
443
+ *
444
+ * Left alone when the file or the key is absent: some plugin versions do not use
445
+ * it, and inventing a section would be worse than doing nothing.
446
+ *
447
+ * @param {string} ini
448
+ * @param {string} value
449
+ * @returns {boolean} whether the file was changed
450
+ */
451
+ function updateImgFile(ini, value) {
452
+ if (!ini || !existsSync(ini)) return false
453
+ let text = ''
454
+ try {
455
+ text = readFileSync(ini, 'utf8')
456
+ } catch {
457
+ return false
458
+ }
459
+ if (!/^ImgFile\s*=/m.test(text)) return false
460
+ const next = text.replace(/^ImgFile\s*=.*$/m, `ImgFile = ${value}`)
461
+ if (next === text) return false
462
+ try {
463
+ writeFileSync(ini, next)
464
+ return true
465
+ } catch {
466
+ return false
467
+ }
236
468
  }
@@ -504,6 +504,10 @@ export function openConsole({ port, host = '127.0.0.1', label, timeoutMs = 10000
504
504
  /**
505
505
  * Probe a console port without keeping a session.
506
506
  *
507
+ * Kept as the documented way to ask "is VRP answering on this port" for callers
508
+ * that need the prompt; `waitForConsole` in `launcher.mjs` is the polling form
509
+ * used while devices boot.
510
+ *
507
511
  * @param {{port: number, host?: string, timeoutMs?: number}} options
508
512
  * @returns {Promise<{port: number, reachable: boolean, prompt: string|null, error?: string}>}
509
513
  */
@@ -303,25 +303,30 @@ export function doctor(options = {}) {
303
303
  checks.push(check('anchors', 'warn', `not measured: ${error.message}`, 'Run `ensp probe` with eNSP visible.'))
304
304
  }
305
305
  } else {
306
+ // Nothing was measured, so there is nothing to report on. Comparing the two
307
+ // fallback constants with each other only proves they are ordered, which is
308
+ // true by construction and says nothing about this machine.
306
309
  const a = DEFAULT_ANCHORS.start
307
310
  const b = DEFAULT_ANCHORS.stop
308
311
  checks.push(
309
312
  check(
310
313
  'anchors',
311
- b.x > a.x ? 'pass' : 'fail',
312
- `fallback start (${a.x},${a.y}) stop (${b.x},${b.y}); eNSP not open, nothing measured`,
313
- b.x > a.x ? undefined : 'Fallback anchors are inconsistent; run `ensp probe`.',
314
+ 'skip',
315
+ `not measured: eNSP is not running; fallback is start (${a.x},${a.y}) stop (${b.x},${b.y})`,
316
+ 'Open eNSP, load a topology and run `ensp doctor --topology <file>` to measure the toolbar.',
314
317
  ),
315
318
  )
316
319
  }
317
320
 
318
321
  const failed = checks.filter((c) => c.status === 'fail')
319
322
  const warned = checks.filter((c) => c.status === 'warn')
323
+ const skipped = checks.filter((c) => c.status === 'skip')
320
324
  return {
321
325
  ok: failed.length === 0,
322
326
  checks,
323
327
  failed: failed.map((c) => c.id),
324
328
  warned: warned.map((c) => c.id),
329
+ skipped: skipped.map((c) => c.id),
325
330
  }
326
331
  }
327
332
 
@@ -339,7 +344,7 @@ function safeWindow() {
339
344
 
340
345
  /** Render a doctor report for a terminal. */
341
346
  export function formatDoctor(report) {
342
- const mark = { pass: 'ok ', warn: 'warn', fail: 'FAIL' }
347
+ const mark = { pass: 'ok ', warn: 'warn', fail: 'FAIL', skip: 'skip' }
343
348
  const lines = []
344
349
  for (const c of report.checks) {
345
350
  lines.push(`[${mark[c.status] || c.status}] ${c.id}: ${c.detail}`)
@@ -354,12 +359,6 @@ export function formatDoctor(report) {
354
359
  return lines.join('\n')
355
360
  }
356
361
 
357
- /** Convenience wrapper used by the CLI, MCP server and plugin. */
358
- export function checkEnvironment(options = {}) {
359
- const report = doctor(options)
360
- return { ...report, text: formatDoctor(report) }
361
- }
362
-
363
362
  /**
364
363
  * Models a topology needs, so `doctor()` can check the right images.
365
364
  * Accepts a parsed topology (from `readTopology`).
@@ -368,5 +367,3 @@ export function modelsOf(topology) {
368
367
  const devices = topology?.devices ?? []
369
368
  return [...new Set(devices.map((d) => d.model))].sort()
370
369
  }
371
-
372
- /** Free-memory constant, exported so tests can assert against it. */