dsh-ensp 0.3.3 → 0.5.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.en.md ADDED
@@ -0,0 +1,339 @@
1
+ <p align="center">
2
+ English | <a href="README.md">中文</a>
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/dsh-ensp"><img alt="npm" src="https://img.shields.io/npm/v/dsh-ensp?style=flat-square&color=6c5ce7"></a>
7
+ <a href="https://www.npmjs.com/package/dsh-ensp"><img alt="npm downloads" src="https://img.shields.io/npm/dm/dsh-ensp?style=flat-square&color=4b6fff"></a>
8
+ <a href="https://github.com/lizi600jin/dsh-ensp/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/lizi600jin/dsh-ensp?style=flat-square&color=eab308"></a>
9
+ <a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-GPL--3.0--or--later-blue?style=flat-square"></a>
10
+ </p>
11
+
12
+ # dsh-ensp
13
+
14
+ Automation for Huawei eNSP. Generates topologies, starts devices, pushes configuration in order, and checks the result of every command.
15
+
16
+ Three calling modes share one core:
17
+
18
+ - CLI: scripted batch operation
19
+ - MCP server: for Claude Desktop, Cursor and similar clients
20
+ - DeepSeek Harness plugin: model tools inside a session
21
+
22
+ ## Features
23
+
24
+ ### Generate topologies
25
+
26
+ Writes `.topo` files that eNSP opens and renders correctly. Works with the built-in scenarios or with devices and links you define yourself.
27
+
28
+ Model and interface layouts live in `src/core/models.mjs` and cover 16 models. When parsing a topology, port names come from the device's own `<interface>` block first; the same model can have a different port layout after an expansion card is added, and that declaration is the only reliable source.
29
+
30
+ ```js
31
+ import { twoPcLab, writeTopology } from 'dsh-ensp'
32
+
33
+ await writeTopology('lab.topo', twoPcLab({ vlan: 10 }))
34
+ ```
35
+
36
+ ```js
37
+ import { buildTopology, autoLayout } from 'dsh-ensp'
38
+
39
+ const topo = autoLayout(
40
+ buildTopology()
41
+ .add('R1', 'AR2220', { x: 180, y: 120 })
42
+ .add('R2', 'AR2220', { x: 460, y: 260 })
43
+ .link('R1', 'GigabitEthernet0/0/0', 'R2', 'GigabitEthernet0/0/0')
44
+ .finish()
45
+ )
46
+ ```
47
+
48
+ A few topology rules come from eNSP itself: interface groups must match the model, and link anchors must fall inside the icon bounding box. If any of them is wrong, eNSP opens a blank canvas and reports nothing. See [`docs/ENSP-FORMAT.md`](docs/ENSP-FORMAT.md).
49
+
50
+ ### Start and stop devices
51
+
52
+ Locates the toolbar buttons automatically, so nothing has to be clicked by hand. After starting, it confirms each device one by one, then minimises the window so the desktop stays usable.
53
+
54
+ ```bash
55
+ ensp probe # re-calibrate the button positions
56
+ ensp up lab.topo # open, start, confirm each device, minimise
57
+ ensp stop # shut every device down
58
+ ```
59
+
60
+ eNSP allows only one instance. An abnormal exit can leave a zombie process with no threads, which keeps holding the single-instance lock and makes the next start open a blank window. The tool identifies and clears such processes before starting.
61
+
62
+ ### Push configuration
63
+
64
+ Sends one command at a time and waits for the prompt after each. A prompt only counts once the echo of that command has arrived, so output still draining from the previous command cannot decide the current view. The first error aborts the batch and reports the line, the command and the device's raw output.
65
+
66
+ A line the device answers with "already exists" (`address already exists`, `VLAN already exists`, `route already exists` and similar) is recorded as skipped rather than failed. Editing one line and re-running is the common loop, and stopping on those lines means the script never reaches the line that actually changed. Every other error still aborts, because a genuine rejection usually means the following lines land on the wrong object.
67
+
68
+ ```js
69
+ import { pushConfig } from 'dsh-ensp'
70
+
71
+ const result = await pushConfig({
72
+ device: 'SW1',
73
+ port: 2000,
74
+ script: `
75
+ system-view
76
+ vlan 10
77
+ quit
78
+ interface GigabitEthernet0/0/1
79
+ port link-type access
80
+ port default vlan 10
81
+ quit
82
+ `,
83
+ })
84
+
85
+ if (!result.ok) {
86
+ console.log(result.failedAt.line, result.failedAt.command, result.failedAt.error)
87
+ }
88
+ ```
89
+
90
+ ### Wait for convergence
91
+
92
+ Routing protocols converge asynchronously, so a finished push does not mean neighbours are up. Such checks poll until the state is reached or the timeout expires.
93
+
94
+ ```js
95
+ import { waitForOspfFull, waitForOspfRoute, waitForOutput } from 'dsh-ensp'
96
+
97
+ await waitForOspfFull({ port: 2000, neighbourId: '2.2.2.2' })
98
+ await waitForOspfRoute({ port: 2000, prefix: '2.2.2.2/32' })
99
+ await waitForOutput({ port: 2000, command: 'display ip routing-table', until: /2\.2\.2\.2/ })
100
+ ```
101
+
102
+ All three return `{ ok, converged, output, attempts, waitedMs }`. A fixed sleep misjudges on a slow machine and wastes time on a fast one.
103
+
104
+ ### Scenario presets
105
+
106
+ 50 presets cover VLAN, STP, stacking, link aggregation, static routing, RIP, OSPF, BGP, IS-IS, MPLS, multicast, NAT, DHCP, ACL, QoS, port security, IPsec, IPv6, WLAN, firewalls and high availability.
107
+
108
+ ```bash
109
+ ensp preset # list by difficulty
110
+ ensp preset ospf-single # read one preset
111
+ ensp preset --search bgp # search by keyword
112
+ ensp preset campus-network --topo campus.topo # write that topology
113
+ ```
114
+
115
+ Each preset provides three things: the objects to create, the configuration order, and the commands that confirm the result. The actual commands are filled in per device.
116
+
117
+ Presets ship with a topology blueprint: devices, links and addressing are ready, and `--topo` writes a `.topo` that eNSP opens, with no drawing or address planning needed. Blueprints contain no configuration commands, so a preset remains a skeleton.
118
+
119
+ ```bash
120
+ ensp preset ospf-single --topo ospf-single.topo
121
+ ensp up ospf-single.topo
122
+ ```
123
+
124
+ The preset catalogue and the blueprints are both generated and checked. `docs/SCENARIOS.md` is rendered from `src/core/presets.mjs` and `src/core/labs.mjs`; `npm run docs:check` returns non-zero when the two disagree, and CI runs the same check.
125
+
126
+ ```bash
127
+ npm run docs # regenerate docs/SCENARIOS.md
128
+ npm run docs:check # check whether it is stale
129
+ ```
130
+
131
+ ### Knowledge points
132
+
133
+ A scenario gives direction, not commands, so the vocabulary of the lab has to come from somewhere. `src/core/knowledge.json` holds knowledge points filed by topic: each scenario ends with a few question-and-answer pairs on the same subject, covering things like which LSA belongs to which area, which attribute decides a BGP path, and which port role only MSTP has.
134
+
135
+ ```bash
136
+ ensp preset ospf-lsa-types # the detail ends with a knowledge section
137
+ ensp preset --knowledge LSA # search the index by keyword
138
+ ```
139
+
140
+ The points come from a certification question bank, re-filed by topic. The bank's own tags are unreliable, so a question about OSPF LSA types can also carry a security tag; each question is scored against topic keywords and kept only under the best match. Questions phrased as "which of the following is wrong" are dropped, because once the distractors are stripped what remains is a false statement. The bank itself is not shipped: `knowledge.json` keeps only the stem and the correct option text.
141
+
142
+ ### Device image check
143
+
144
+ Some models have a catalogue entry without a shipped image, most notably `USG6000V` (its component has to be selected during eNSP installation). Such models can be placed on the canvas and saved to a `.topo`, but eNSP renders the topology and then quits, which shows up as consoles that never answer.
145
+
146
+ `ensp up` reads `res/items.xml` and the plugin image files before starting the client, and reports models that cannot start together with the missing piece:
147
+
148
+ ```console
149
+ $ ensp up firewall-hrp.topo
150
+ error: topology uses devices this eNSP installation cannot start:
151
+ USG6000V (FW1, FW2): NGFW 镜像未安装(eNSP 安装时需勾选 USG6000V 组件)
152
+
153
+ This is not a bug in the topology: eNSP opens it, renders it, then quits. ...
154
+ ```
155
+
156
+ `ensp preset ... --topo` prints the same warning, and `ensp_build_topology` returns `unsupportedDevices`. The check only reads local files and does not start eNSP. Per-scenario device lists appear in [docs/SCENARIOS.md](docs/SCENARIOS.md) under the device image line.
157
+
158
+ ### Window occlusion check
159
+
160
+ Starting devices requires clicking eNSP toolbar buttons, and a synthetic click goes to the window that owns that pixel. When another program covers eNSP, the mouse events go there and eNSP receives no input.
161
+
162
+ Before clicking, the tool checks the owner of the target point with `WindowFromPoint` and skips the click when the owner is not eNSP:
163
+
164
+ ```console
165
+ $ ensp up lab.topo
166
+ error: eNSP is covered by another window at 1307,426: vmware (unknown).
167
+ The mouse events would go to that window, so nothing was clicked.
168
+ Bring eNSP to the front (restore it from the taskbar) and try again.
169
+ ```
170
+
171
+ Calibration samples the window on a 5 by 4 grid and stops once any sample point belongs to another window. Calibration reads screen pixels, so an occluded window yields another program's pixels. After the click the owner is checked once more.
172
+
173
+ The tool cannot raise the eNSP window. Windows lets only the process holding foreground rights switch the foreground window, and a cross-process `SetForegroundWindow` is refused. Keep eNSP on top before running.
174
+
175
+ ### Run permissions
176
+
177
+ This tool must run as administrator.
178
+
179
+ The eNSP client requires administrator rights and always runs at high integrity. Windows user interface privilege isolation (UIPI) drops input messages sent by lower-integrity processes, and synthetic clicks are among them. At normal privileges `SendInput` reports success but the input queue does not change, so eNSP receives nothing and clicks appear to do nothing. Started as administrator, input injection works.
180
+
181
+ Administrator rights solve input injection, not window stacking. eNSP still has to stay visible and on top.
182
+
183
+ Services, scheduled tasks and SSH sessions have no input-injection capability, and elevation does not change that. The `input-injection` check of `ensp doctor` reports whether the current session can be used.
184
+
185
+ ### Doctor
186
+
187
+ When a start fails, locating the faulty item usually costs more than fixing it. `ensp doctor` splits the environment into seven checks, decides each one, and gives an actionable fix when one fails:
188
+
189
+ ```console
190
+ $ ensp doctor --topology lab.topo
191
+ [ok ] ensp-installed: E:\\Huawei\\eNSP\\eNSP_Client.exe
192
+ [ok ] virtualbox: E:\\VirtualBox\\VBoxManage.exe
193
+ [ok ] console-ports: 2000-2100 free
194
+ [warn] memory: 949 MB free (each router needs ~256 MB)
195
+ fix: Close some applications; devices may boot very slowly or fail to start.
196
+ [ok ] device-images: AR2220 available
197
+ [FAIL] input-injection: SendInput accepted 2 event(s) but the session last-input tick did not change (session 1, console session 1)
198
+ fix: Run the tool elevated (as administrator) from an interactive desktop session.
199
+ [ok ] anchors: start (987,226) stop (1049,228)
200
+ ```
201
+
202
+ The command does not start eNSP; it reads local files and runs one synthetic input probe, so it can run after any failure or before any operation. `--shallow` skips the slower input probe and `--json` prints the full structure. The matching tool is `ensp_doctor`.
203
+
204
+ The check order is fixed: client executable, VirtualBox, topology images, console ports, input injection, memory, anchors. Fix only the item that fails.
205
+
206
+ ### Verification
207
+
208
+ A successful push does not mean the lab works. `ensp_verify` reads console ports from the `.topo`, runs the verification commands of that scenario, and judges by output content rather than by whether the command reported an error:
209
+
210
+ ```console
211
+ $ ensp verify ospf-single --topology lab.topo
212
+ ospf-single · 单区域 OSPF
213
+ [curated checks]
214
+ [pass] neighbour-full on R1: display ospf peer brief
215
+ [pass] ospf-route on R1: display ip routing-table protocol ospf
216
+
217
+ accepted: 2/2 checks passed
218
+ ```
219
+
220
+ About 30 scenarios have targeted assertions (OSPF needs `Full`, BGP needs `Established`, VRRP needs `Master`/`Backup`, MPLS VPN needs `Established` and a VPN routing table); the rest fall back to running the verification commands listed in the scenario. The decision logic lives in `EXPECTATIONS` in `src/core/acceptance.mjs`, and assertions support `contains`, `notContains`, `matches` and `minLines`. The matching tool is `ensp_verify`, and `ensp_scenario` reports whether a scenario has targeted assertions.
221
+
222
+ Device names in assertions map by role to the topology you actually have: blueprints use `R1`/`R2` while course assignments may use `RA`/`RB`/`RC`. Verification takes the first device with a console (`R2` takes the second), so a different name does not change the result.
223
+
224
+ Failed assertions are retried automatically. Routing protocols converge after the command returns, and right after `ensp apply` an OSPF neighbour is usually still in `ExStart` or `2-Way`, so a single sample would report a working lab as broken. The default wait is 60 seconds, `--wait` changes it, and a pass records how long it actually waited:
225
+
226
+ ```console
227
+ $ ensp verify ospf-single --topology lab.topo
228
+ checking neighbour-full on R1
229
+ waiting 3s for neighbour-full on R1 (not contains "Full")
230
+ ...
231
+ [pass] neighbour-full on R1: display ospf peer brief (converged after 27s)
232
+ ```
233
+
234
+ Two kinds of failure skip the retry loop because retrying changes nothing: an unreachable console (device not started) and a command VRP rejects (wrong command). The former reports `console unreachable: ... ECONNREFUSED`, the latter reports `VRP rejected the command`; neither is disguised as an assertion mismatch.
235
+
236
+ The recommended order is `doctor`, `up`, `verify`: doctor decides the environment, up decides the devices, verify decides the configuration effect.
237
+
238
+ ## Installation
239
+
240
+ ### As a DSH plugin
241
+
242
+ Search `dsh-ensp` in the plugin market and install it, or:
243
+
244
+ ```console
245
+ dsh plugin --profile desktop add dsh-ensp
246
+ ```
247
+
248
+ ### As a CLI or library
249
+
250
+ ```console
251
+ npm install -g dsh-ensp
252
+ ensp status
253
+ ```
254
+
255
+ ### As an MCP server
256
+
257
+ ```json
258
+ {
259
+ "mcpServers": {
260
+ "ensp": {
261
+ "command": "npx",
262
+ "args": ["-y", "dsh-ensp", "mcp"]
263
+ }
264
+ }
265
+ }
266
+ ```
267
+
268
+ 15 tools are provided: `ensp_status`, `ensp_build_topology`, `ensp_open`, `ensp_start`, `ensp_stop`, `ensp_apply`, `ensp_apply_dir`, `ensp_query`, `ensp_probe`, `ensp_doctor`, `ensp_verify`, `ensp_scenarios`, `ensp_scenario`, `ensp_knowledge` and `ensp_preset_config`.
269
+
270
+ ## Requirements
271
+
272
+ | Item | Requirement |
273
+ |---|---|
274
+ | OS | Windows. eNSP has no other build |
275
+ | eNSP | Installed and working |
276
+ | Node.js | 18 or newer |
277
+ | Privileges | Run both this tool and eNSP as administrator, at the same integrity level |
278
+
279
+ The eNSP and VirtualBox install locations are detected automatically: `--exe` / `--vbox` first, then the `DSH_ENSP_EXE` / `DSH_VBOX` environment variables, then the `HKLM\SOFTWARE\Huawei\eNSP` registry key, then the usual install roots and `PATH`. Nothing needs configuring when either is installed on another drive, and a failed lookup lists every location it tried together with where that location came from.
280
+
281
+ At 125% or 150% display scaling the tool converts coordinates itself, so no resolution change is needed. If an eNSP upgrade moves the buttons, run `ensp probe` to re-calibrate.
282
+
283
+ ## Quick start
284
+
285
+ Run as administrator (see "Run permissions" above):
286
+
287
+ ```bash
288
+ ensp doctor # confirm this machine can run a lab
289
+ ensp preset ospf-single --topo lab.topo # write a topology
290
+ ensp up lab.topo # start the devices
291
+ ensp apply ./configs --topology lab.topo --save
292
+ ensp verify ospf-single --topology lab.topo # verify against the scenario
293
+ ensp stop
294
+ ```
295
+
296
+ Timing for one two-area OSPF lab (measured on Windows 11, i5, 150% scaling):
297
+
298
+ | Stage | Time |
299
+ |---|---|
300
+ | Write the `.topo` | 2s |
301
+ | Open and render the topology | 14s |
302
+ | Two AR2220 to a loginable prompt | 64s |
303
+ | Push 9 commands to each side | 8s |
304
+ | OSPF neighbours to Full | 40s |
305
+
306
+ ## Thanks and licence
307
+
308
+ ### Licence
309
+
310
+ [GPL-3.0-or-later](LICENSE).
311
+
312
+ Redistributing a modified version requires:
313
+
314
+ 1. Keeping the copyright notice in `LICENSE`;
315
+ 2. Naming the original project `dsh-ensp` and its repository <https://github.com/lizi600jin/dsh-ensp> in the source and the accompanying documentation;
316
+ 3. Licensing the distributed work under GPL-3.0-or-later as well.
317
+
318
+ None of this applies when you use the project yourself without redistributing it.
319
+
320
+ MIT and Apache-2.0 were not chosen because both allow a modified version to be distributed closed source, which conflicts with the requirement that modifications also be published.
321
+
322
+ ### Thanks
323
+
324
+ - Huawei eNSP: the simulator itself.
325
+ - The users who reported problems in issues.
326
+
327
+ ## Disclaimer
328
+
329
+ This project is an automation shell. It contains neither eNSP nor any Huawei software or image. Install eNSP yourself and follow Huawei's licence terms.
330
+
331
+ Automating a graphical interface carries inherent risk, for example an occluded window, an eNSP upgrade or a change in display scaling. Validate in a non-critical environment first.
332
+
333
+ ## Documentation
334
+
335
+ | Document | Content |
336
+ |---|---|
337
+ | [`docs/ENSP-FORMAT.md`](docs/ENSP-FORMAT.md) | `.topo` format rules |
338
+ | [`docs/UI-ANCHORS.md`](docs/UI-ANCHORS.md) | Interface coordinates and startup flow |
339
+ | [`docs/SCENARIOS.md`](docs/SCENARIOS.md) | Scenario presets |
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <strong>中文</strong>
2
+ <strong>中文</strong> | <a href="README.en.md">English</a>
3
3
  </p>
4
4
 
5
5
  <p align="center">
@@ -61,7 +61,9 @@ eNSP 只允许运行一个实例。异常退出可能遗留不占线程的僵尸
61
61
 
62
62
  ### 下发配置
63
63
 
64
- 逐条发送,每条命令确认提示符返回后才继续。任一命令出错即中断,并回报行号、命令与设备原始输出。
64
+ 逐条发送,每条命令确认提示符返回后才继续。提示符必须在本条命令的回显之后出现,否则视为上一条命令的输出尚未结束,不会据此判断视图。任一命令出错即中断,并回报行号、命令与设备原始输出。
65
+
66
+ 设备回答“已存在”的行(`address already exists`、`VLAN already exists`、`route already exists` 等)视为已达目标状态,记为跳过而不是失败。改一行再重跑是最常见的用法,如果这些行一出现就中断,脚本永远跑不到真正改动的那一行。其他错误仍然中断,因为真正的拒绝通常意味着后面的行也会落到错误的对象上。
65
67
 
66
68
  ```js
67
69
  import { pushConfig } from 'dsh-ensp'
@@ -121,6 +123,17 @@ ensp up ospf-single.topo
121
123
 
122
124
  场景清单与蓝图均可生成、可校验。`docs/SCENARIOS.md` 由 `src/core/presets.mjs` 与 `src/core/labs.mjs` 渲染,`npm run docs:check` 在两者不一致时返回非零,CI 亦执行该检查。
123
125
 
126
+ ### 知识点
127
+
128
+ 场景说明只给方向,命令由使用者补全,因此需要知道该场景涉及的术语。`src/core/knowledge.json` 收录按主题归类的知识点:每个场景在末尾附上同主题的若干「问题 + 答案」,涵盖 LSA 类型归属、BGP 选路属性、MSTP 端口角色等。
129
+
130
+ ```bash
131
+ ensp preset ospf-lsa-types # 详情末尾带知识点小节
132
+ ensp preset --knowledge LSA # 按关键词检索知识点
133
+ ```
134
+
135
+ 知识点来自认证题库,按主题重新归类:题目的原始标签并不可靠,同一道 OSPF 题可能同时被标为安全,因此按正文关键词评分后只归入最匹配的主题,并剔除「以下错误的是」这类去掉干扰项后只剩错误陈述的题目。题库原文不随包分发,`knowledge.json` 只保留题干与正确选项文本。
136
+
124
137
  ```bash
125
138
  npm run docs # 重新生成 docs/SCENARIOS.md
126
139
  npm run docs:check # 只检查是否过期
@@ -252,7 +265,7 @@ ensp status
252
265
  }
253
266
  ```
254
267
 
255
- 提供 14 个工具:`ensp_status`、`ensp_build_topology`、`ensp_open`、`ensp_start`、`ensp_stop`、`ensp_apply`、`ensp_apply_dir`、`ensp_query`、`ensp_probe`、`ensp_doctor`、`ensp_verify`、`ensp_scenarios`、`ensp_scenario`、`ensp_preset_config`。
268
+ 提供 15 个工具:`ensp_status`、`ensp_build_topology`、`ensp_open`、`ensp_start`、`ensp_stop`、`ensp_apply`、`ensp_apply_dir`、`ensp_query`、`ensp_probe`、`ensp_doctor`、`ensp_verify`、`ensp_scenarios`、`ensp_scenario`、`ensp_knowledge`、`ensp_preset_config`。
256
269
 
257
270
  ## 环境要求
258
271
 
@@ -263,6 +276,8 @@ ensp status
263
276
  | Node.js | 18 或更高版本 |
264
277
  | 权限 | 以管理员身份运行本工具与 eNSP,两者需为同一权限级别 |
265
278
 
279
+ eNSP 与 VirtualBox 的安装位置自动探测:先看 `--exe` / `--vbox`,再看 `DSH_ENSP_EXE` / `DSH_VBOX` 环境变量,再看注册表 `HKLM\SOFTWARE\Huawei\eNSP`,最后看常见安装根目录与 `PATH`。装在非默认盘符时不必手工配置,探测失败会列出每一个尝试过的位置及其来源。
280
+
266
281
  系统缩放设为 125% 或 150% 时,程序会自行完成坐标换算,不需要手动调分辨率。若 eNSP 升级后按钮位置改变,执行 `ensp probe` 重新标定。
267
282
 
268
283
  ## 快速开始
@@ -48,9 +48,15 @@
48
48
  4. 点击「开启设备」= window-relative (660, 59)
49
49
  5. 判定启动成功:Get-NetTCPConnection 出现 2000..2100 的监听端口
50
50
  (AR1220 约 60~120s;这不是“进程在不在”,是唯一可靠判据)
51
- 6. SW_MINIMIZE 最小化,把桌面还给用户
51
+ 6. SW_MINIMIZE 最小化,并把激活交还给最小化之前的那个窗口
52
52
  ```
53
53
 
54
+ 最小化只改窗口状态,不转移激活。`ShowWindow(h, SW_MINIMIZE)` 之后窗口已经 iconic,
55
+ 但 `GetForegroundWindow()` 仍然是 eNSP;此时从任务栏点它,第一击会被 Windows 当成
56
+ “修正激活一致性”吞掉,表现为点了没反应、要再点一次。因此最小化后必须显式
57
+ `SetForegroundWindow(previousForeground)`,并在 120ms 后回读确认。
58
+ 停止设备同理:点击需要 eNSP 在前台,但用户要的是停止,操作结束后不该把 eNSP 留在前台。
59
+
54
60
  ### 关键纠正(都来自实测,推翻了早期假设)
55
61
 
56
62
  | 早期假设 | 实测结论 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ensp",
3
- "version": "0.3.3",
3
+ "version": "0.5.0",
4
4
  "description": "Automate Huawei eNSP labs: generate .topo files, start and stop devices, and push configuration one command at a time with per-command error reporting.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -40,13 +40,15 @@
40
40
  "cordis.patch.yml",
41
41
  "screenshots.json",
42
42
  "README.md",
43
+ "README.en.md",
43
44
  "LICENSE"
44
45
  ],
45
46
  "scripts": {
46
47
  "test": "node --test tests/core.test.mjs",
47
48
  "probe": "node src/cli/ensp.mjs probe",
48
49
  "docs": "node scripts/gen-scenarios-doc.mjs",
49
- "docs:check": "node scripts/gen-scenarios-doc.mjs --check"
50
+ "docs:check": "node scripts/gen-scenarios-doc.mjs --check",
51
+ "knowledge": "node scripts/gen-knowledge.mjs"
50
52
  },
51
53
  "engines": {
52
54
  "node": ">=18"
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Regenerate `src/core/knowledge.json` from a certification question bank.
3
+ *
4
+ * The bank is not part of this package: it is copyrighted material, and most of
5
+ * its questions are three-sentence reading exercises rather than facts. What the
6
+ * package does ship is the index this script produces, so the transformation has
7
+ * to be reproducible and reviewable by anyone who has the bank.
8
+ *
9
+ * Usage:
10
+ * node scripts/gen-knowledge.mjs <bank.json>
11
+ *
12
+ * The bank is expected as an array of
13
+ * `{ n, type, stem, options: [{key, text}], answer, analysis, topics }`.
14
+ *
15
+ * Two rules shape the output, and both exist because the obvious version of this
16
+ * script produced a worse index:
17
+ *
18
+ * 1. **One topic per question.** The bank's own tags are unreliable: a question
19
+ * about OSPF LSA types carries `ospf` and `security` both, and honouring every
20
+ * tag files the same fact under two headings. Score the text against topic
21
+ * keywords and keep the best match.
22
+ * 2. **Drop "which is wrong" questions.** Once the distractors are removed, the
23
+ * surviving string is a false statement, which is worse than no material.
24
+ */
25
+
26
+ import { readFileSync, writeFileSync } from 'node:fs'
27
+ import { fileURLToPath } from 'node:url'
28
+ import path from 'node:path'
29
+
30
+ /** Topic tag -> the scenarios that teach it. */
31
+ const TOPIC_SCENARIOS = {
32
+ ospf: ['ospf-single', 'ospf-multi-area', 'ospf-stub-nssa', 'ospf-lsa-types', 'ospf-virtual-link', 'ospf-troubleshooting'],
33
+ bgp: ['bgp-ebgp-ibgp', 'bgp-route-reflector', 'bgp-attributes', 'bgp-aggregation', 'bgp-troubleshooting'],
34
+ isis: ['isis-multi-level'],
35
+ switching: [
36
+ 'vlan-access',
37
+ 'trunk-interconnect',
38
+ 'stp-rstp',
39
+ 'mstp-load-balance',
40
+ 'link-aggregation',
41
+ 'super-vlan',
42
+ 'port-security',
43
+ 'port-isolation',
44
+ 'stack-istack',
45
+ ],
46
+ multicast: ['multicast-pim-sm'],
47
+ mpls: ['mpls-basic', 'mpls-l3vpn'],
48
+ ipv6: ['ipv6', 'ospfv3-ipv6'],
49
+ qos: ['qos-basics'],
50
+ reliability: ['vrrp-basics', 'bfd-fast-convergence', 'firewall-hrp', 'dual-homed-isp'],
51
+ security: ['acl-filter', 'aaa-local', 'ipsec-vpn', 'firewall-zones', 'firewall-hrp', 'arp-security', 'dhcp-snooping'],
52
+ }
53
+
54
+ /** Keywords that decide which topic a question belongs to. */
55
+ const TOPIC_TERMS = {
56
+ ospf: ['ospf', 'lsa', 'abr', 'asbr', 'nssa', 'stub', 'router-id'],
57
+ bgp: ['bgp', 'as-path', 'ibgp', 'ebgp', 'community', 'aggregator', 'med', 'local-preference'],
58
+ isis: ['is-is', 'isis', 'level-1', 'level-2', 'dis', 'lsp', 'att'],
59
+ switching: ['vlan', 'mstp', 'rstp', 'stp', 'eth-trunk', '端口隔离', '端口安全', '堆叠', '根桥', 'bpdu'],
60
+ multicast: ['pim', 'igmp', '组播', 'rpf', 'spt'],
61
+ mpls: ['mpls', 'ldp', 'lsr', '标签', 'vpn-instance'],
62
+ ipv6: ['ipv6', 'ospfv3', 'slaac'],
63
+ qos: ['qos', '队列', '拥塞', '流量监管', '优先级映射'],
64
+ reliability: ['vrrp', 'bfd', 'hrp', '双机热备', '主备'],
65
+ security: ['acl', '安全区域', 'security-policy', 'ipsec', 'ike', 'aaa', 'radius', '防火墙', 'nat'],
66
+ }
67
+
68
+ /** A question phrased this way keeps only a false statement once stripped. */
69
+ const NEGATIVE = /错误的是|不正确的是|不属于|不支持|不能|无需/
70
+
71
+ /** Points kept per topic. Enough to cover the vocabulary, short enough to read. */
72
+ const MAX_POINTS = 8
73
+
74
+ /** A question must score at least this many keyword hits to be filed at all. */
75
+ const MIN_SCORE = 2
76
+
77
+ const clean = (s) => String(s ?? '').replace(/\s+/g, ' ').trim()
78
+ const clip = (s, n) => (s.length <= n ? s : s.slice(0, n - 1).replace(/[,,、;;:: .]*$/, '') + '…')
79
+
80
+ /**
81
+ * The answer as readable text.
82
+ *
83
+ * A bare key list (`A,C`) teaches nothing, so every chosen option is spelled out.
84
+ * Returns '' when the answer is not machine-readable, which is the case for the
85
+ * drag-and-drop and fill-in-the-blank questions.
86
+ */
87
+ function answerText(q) {
88
+ const keys = clean(q.answer).toUpperCase().match(/[A-Z]/g)
89
+ if (!keys || !q.options?.length) return q.type === '判断题' ? clean(q.answer) : ''
90
+ const picked = q.options.filter((o) => keys.includes(String(o.key).toUpperCase()))
91
+ if (!picked.length) return ''
92
+ return picked.map((o) => `${o.key}. ${clip(clean(o.text), 70)}`).join(' / ')
93
+ }
94
+
95
+ /**
96
+ * Build the index.
97
+ *
98
+ * @param {Array<object>} bank
99
+ * @returns {object}
100
+ */
101
+ export function buildIndex(bank) {
102
+ const perTopic = {}
103
+ for (const q of bank) {
104
+ const answer = answerText(q)
105
+ if (!answer) continue
106
+ const stem = clean(q.stem)
107
+ if (NEGATIVE.test(stem)) continue
108
+ const hay = (stem + ' ' + clean(q.analysis)).toLowerCase()
109
+ let best = null
110
+ let bestScore = 0
111
+ for (const [topic, terms] of Object.entries(TOPIC_TERMS)) {
112
+ const score = terms.reduce((sum, t) => sum + (hay.split(t).length - 1), 0)
113
+ if (score > bestScore) {
114
+ bestScore = score
115
+ best = topic
116
+ }
117
+ }
118
+ if (!best || bestScore < MIN_SCORE) continue
119
+ ;(perTopic[best] ||= []).push({ n: q.n, q: clip(stem, 90), a: answer, score: bestScore })
120
+ }
121
+
122
+ const topics = {}
123
+ for (const [topic, rows] of Object.entries(perTopic)) {
124
+ // Highest keyword score first, then the shortest stem: a one-line question
125
+ // states a fact, while a long one describes a scene.
126
+ const ranked = rows.slice().sort((a, b) => b.score - a.score || a.q.length - b.q.length)
127
+ const seen = new Set()
128
+ const points = []
129
+ for (const r of ranked) {
130
+ const key = r.q.slice(0, 40) + '|' + r.a.slice(0, 30)
131
+ if (seen.has(key)) continue
132
+ seen.add(key)
133
+ points.push({ n: r.n, q: r.q, a: r.a })
134
+ if (points.length >= MAX_POINTS) break
135
+ }
136
+ topics[topic] = { scenarios: TOPIC_SCENARIOS[topic], points }
137
+ }
138
+
139
+ return {
140
+ generatedFrom: 'HCIP H12-821 单选/多选/判断题',
141
+ sourceQuestions: bank.length,
142
+ topics,
143
+ }
144
+ }
145
+
146
+ const invokedDirectly =
147
+ process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
148
+
149
+ if (invokedDirectly) {
150
+ const bankPath = process.argv[2]
151
+ if (!bankPath) {
152
+ console.error('usage: node scripts/gen-knowledge.mjs <bank.json>')
153
+ process.exit(2)
154
+ }
155
+ const bank = JSON.parse(readFileSync(bankPath, 'utf8'))
156
+ const index = buildIndex(bank)
157
+ const target = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'core', 'knowledge.json')
158
+ writeFileSync(target, JSON.stringify(index, null, 1) + '\n', 'utf8')
159
+ const total = Object.values(index.topics).reduce((sum, t) => sum + t.points.length, 0)
160
+ console.log(`wrote src/core/knowledge.json (${total} points from ${bank.length} questions)`)
161
+ }
package/src/cli/ensp.mjs CHANGED
@@ -35,6 +35,7 @@ import { doctor, formatDoctor, modelsOf } from '../core/doctor.mjs'
35
35
  import { runAcceptance, formatAcceptance, hasChecks, planFor } from '../core/acceptance.mjs'
36
36
  import { pushConfig } from '../core/provision.mjs'
37
37
  import { SCENARIOS, SCENARIO_IDS, byLevel, renderScenario, searchScenarios } from '../core/presets.mjs'
38
+ import { knowledgeFor, renderKnowledge, searchKnowledge } from '../core/knowledge.mjs'
38
39
  import {
39
40
  LAB_IDS,
40
41
  buildScenarioTopology,
@@ -76,6 +77,8 @@ ${C.bold('OPTIONS')}
76
77
  --wait MS verify: how long a failing assertion may keep being retried (default 60000)
77
78
  --topo PATH write the scenario's topology blueprint to a .topo file
78
79
  --topology PATH the .topo file that is running (doctor, verify)
80
+ --exe PATH full path to eNSP_Client.exe (default: auto-detect via registry and install roots)
81
+ --vbox PATH full path to VBoxManage.exe (default: auto-detect via PATH and install roots)
79
82
  --shallow doctor: skip the input-injection probe
80
83
  --json machine-readable output
81
84
  -h, --help show this help
@@ -86,6 +89,7 @@ ${C.bold('EXAMPLES')}
86
89
  ensp verify ospf-single --topology lab.topo
87
90
  ensp apply ./configs --topology lab.topo --save
88
91
  ensp status --topology lab.topo
92
+ ensp up lab.topo --exe "C:/Program Files/Huawei/eNSP/eNSP_Client.exe"
89
93
  ensp probe
90
94
  ensp preset --search ospf
91
95
  ensp preset ospf-single
@@ -317,6 +321,23 @@ async function cmdStatus({ flags }) {
317
321
  async function cmdPreset({ positional, flags }) {
318
322
  const id = positional[0]
319
323
 
324
+ if (flags.knowledge) {
325
+ const hits = searchKnowledge(String(flags.knowledge))
326
+ if (flags.json) {
327
+ console.log(JSON.stringify(hits, null, 2))
328
+ return hits.length ? 0 : 1
329
+ }
330
+ if (!hits.length) {
331
+ console.log(C.yellow('no knowledge point matched') + ` "${flags.knowledge}"`)
332
+ return 1
333
+ }
334
+ for (const h of hits) {
335
+ console.log(`${C.bold('#' + h.n)} ${C.dim('[' + h.topic + ']')} ${h.q}`)
336
+ console.log(` 答: ${h.a}`)
337
+ }
338
+ return 0
339
+ }
340
+
320
341
  if (flags.search) {
321
342
  const hits = searchScenarios(String(flags.search))
322
343
  if (flags.json) {
@@ -389,7 +410,10 @@ async function cmdPreset({ positional, flags }) {
389
410
  }
390
411
 
391
412
  const text = renderScenario(id)
392
- console.log(flags.json ? JSON.stringify(SCENARIOS[id], null, 2) : text)
413
+ const points = knowledgeFor(id)
414
+ console.log(flags.json ? JSON.stringify({ ...SCENARIOS[id], knowledge: points }, null, 2) : text)
415
+ const knowledge = renderKnowledge(id)
416
+ if (knowledge) console.log(knowledge)
393
417
  if (hasLab(id)) {
394
418
  console.log(C.dim(`\n可生成拓扑: ensp preset ${id} --topo ${id}.topo`))
395
419
  }
@@ -518,6 +542,10 @@ async function cmdVerify({ positional, flags }) {
518
542
  // ------------------------------------------------------------------------- main
519
543
  async function main() {
520
544
  const { verb, positional, flags } = parseArgs(process.argv.slice(2))
545
+ // --exe and --vbox are global: every verb that launches or inspects eNSP has
546
+ // to see the same install, and the launcher reads these variables anyway.
547
+ if (flags.exe) process.env.DSH_ENSP_EXE = String(flags.exe)
548
+ if (flags.vbox) process.env.DSH_VBOX = String(flags.vbox)
521
549
  if (!verb || flags.help) {
522
550
  console.log(HELP)
523
551
  return flags.help && !verb ? 0 : 2