dsh-ensp 0.7.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.
- package/README.en.md +86 -16
- package/README.md +85 -15
- package/docs/ENSP-FORMAT.md +8 -8
- package/docs/SCENARIOS.md +332 -286
- package/docs/UI-ANCHORS.md +21 -0
- package/docs/plugin-detail.en.md +2 -0
- package/docs/plugin-detail.zh.md +2 -0
- package/package.json +2 -4
- package/scripts/gen-scenarios-doc.mjs +7 -3
- package/scripts/survey-topologies.mjs +15 -6
- package/src/cli/ensp.mjs +252 -28
- package/src/core/acceptance.mjs +258 -53
- package/src/core/build.mjs +15 -1
- package/src/core/compat.mjs +249 -17
- package/src/core/console.mjs +4 -0
- package/src/core/doctor.mjs +9 -12
- package/src/core/docx.mjs +359 -0
- package/src/core/index.mjs +28 -9
- package/src/core/labconfig.mjs +98 -0
- package/src/core/labs.mjs +12 -12
- package/src/core/launcher.mjs +96 -37
- package/src/core/models.mjs +27 -18
- package/src/core/ooxml.mjs +430 -0
- package/src/core/paths.mjs +59 -10
- package/src/core/presets.mjs +9 -5
- package/src/core/report.mjs +411 -0
- package/src/dsh/index.mjs +626 -652
- package/src/mcp/server.mjs +213 -356
- package/src/tools.mjs +271 -0
- package/screenshots.json +0 -3
- package/scripts/gen-knowledge.mjs +0 -196
- package/src/core/knowledge.json +0 -959
- package/src/core/knowledge.mjs +0 -103
package/docs/UI-ANCHORS.md
CHANGED
|
@@ -120,6 +120,27 @@
|
|
|
120
120
|
所以置顶问题必须由用户解决。但提权是输入注入的前提:Medium 上下文里 `SendInput` 虽然返回成功,
|
|
121
121
|
输入队列却不变,eNSP 收不到点击;以管理员身份运行后输入注入恢复正常。因此本工具需要以管理员身份运行。
|
|
122
122
|
|
|
123
|
+
## 拓扑是否真的打开了(渲染判定)
|
|
124
|
+
|
|
125
|
+
`ensp open` 打开 `.topo` 之后要确认 eNSP 真的把拓扑画出来了,否则会在空白画布上继续点"启动设备"。
|
|
126
|
+
这个判定以前用像素统计:数画布上饱和蓝的图标像素与近黑的连线像素,两项都超过阈值就算渲染成功。
|
|
127
|
+
实测证明两边都错:
|
|
128
|
+
|
|
129
|
+
| 画面 | 图标像素 | 连线像素 | 旧判定 |
|
|
130
|
+
|---|---|---|---|
|
|
131
|
+
| 单台交换机 | 1983 | 16 | 未渲染,重试三次后报错 |
|
|
132
|
+
| 两台路由器加一条线 | 2986 | 352 | 渲染成功 |
|
|
133
|
+
| 欢迎页(没加载任何拓扑) | 4010 | 2517 | 渲染成功 |
|
|
134
|
+
|
|
135
|
+
单设备实验没有连线,连线像素必然接近 0,于是合法的实验被判为没打开,`launchEnsp` 会重试三次
|
|
136
|
+
然后放弃,用户看到的是同一份拓扑被反复打开。反过来,欢迎页的示例树与缩略图颜色足够鲜艳,
|
|
137
|
+
像素数比真实拓扑还高,会被当成渲染成功。
|
|
138
|
+
|
|
139
|
+
真正的信号在窗口树里:eNSP 给画布上每台设备生成一个隐藏的 `Edit` 子窗口,数量与设备数完全一致。
|
|
140
|
+
欢迎页是 0 个,一台设备是 1 个,三台设备是 3 个。实测四种情况后改用这个计数,
|
|
141
|
+
`topologyRendered(hwnd)` 现在是 `canvasDeviceCount(hwnd) >= 1`。`canvasDeviceCount` 单独导出,
|
|
142
|
+
可以顺带知道画布上有几台设备。
|
|
143
|
+
|
|
123
144
|
## 设备控制台
|
|
124
145
|
|
|
125
146
|
| 项 | 值 |
|
package/docs/plugin-detail.en.md
CHANGED
|
@@ -10,7 +10,9 @@ Automation for Huawei eNSP: generate topologies, boot devices, push configuratio
|
|
|
10
10
|
- Checks that every device image is installed before launching, and names the missing component instead of letting eNSP open the topology and quit silently
|
|
11
11
|
- Runs seven preflight checks (images, ports, memory, input injection), each with a stated fix, then verifies the scenario against what the devices actually report
|
|
12
12
|
- Must run elevated, with the eNSP window visible (see the run-permissions section in the repository documentation)
|
|
13
|
+
- The blueprint addressing plan becomes interface commands with `--config`, or one `.cfg` per device that `ensp apply` reads directly
|
|
13
14
|
- Each scenario points at the vendor manual chapter that teaches it, with that chapter's goals and the display commands it uses to prove the result; the index is searchable
|
|
15
|
+
- Writes a finished lab out as a Word report on request: six fixed chapters, configuration quoted from the devices, verification output quoted verbatim
|
|
14
16
|
- Usable as a CLI, an MCP server, or a DeepSeek Harness plugin
|
|
15
17
|
|
|
16
18
|
Full documentation: [repository](https://github.com/lizi600jin/dsh-ensp#readme).
|
package/docs/plugin-detail.zh.md
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
- 启动前检查机型镜像是否齐全,缺镜像时说明缺失组件,避免 eNSP 打开后静默退出
|
|
11
11
|
- 提供七项环境自检(镜像、端口、内存、输入注入能力),逐条给出结论与修法;配置下发后按场景断言验收
|
|
12
12
|
- 需要以管理员身份运行,并保持 eNSP 窗口可见(详见仓库文档“运行权限”)
|
|
13
|
+
- 场景蓝图自带地址规划,可用 `--config` 输出成接口配置,或写成每台设备一个 `.cfg` 直接交给 `ensp apply`
|
|
13
14
|
- 场景附带厂商实验手册的章节索引:该章的学习目标与官方用来验证结果的 display 命令,可按关键词检索
|
|
15
|
+
- 按需把一次实验写成 Word 报告:六章固定结构,配置取自设备实际生效的内容,验证输出逐条附原文
|
|
14
16
|
- 提供 CLI、MCP Server 与 DeepSeek Harness 插件三种调用方式
|
|
15
17
|
|
|
16
18
|
完整说明见 [仓库文档](https://github.com/lizi600jin/dsh-ensp#readme)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ensp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -43,17 +43,15 @@
|
|
|
43
43
|
"docs",
|
|
44
44
|
"scripts",
|
|
45
45
|
"cordis.patch.yml",
|
|
46
|
-
"screenshots.json",
|
|
47
46
|
"README.md",
|
|
48
47
|
"README.en.md",
|
|
49
48
|
"LICENSE"
|
|
50
49
|
],
|
|
51
50
|
"scripts": {
|
|
52
|
-
"test": "node --test tests/core.test.mjs",
|
|
51
|
+
"test": "node --test tests/core.test.mjs tests/ooxml.test.mjs",
|
|
53
52
|
"probe": "node src/cli/ensp.mjs probe",
|
|
54
53
|
"docs": "node scripts/gen-scenarios-doc.mjs",
|
|
55
54
|
"docs:check": "node scripts/gen-scenarios-doc.mjs --check",
|
|
56
|
-
"knowledge": "node scripts/gen-knowledge.mjs",
|
|
57
55
|
"curriculum": "node scripts/gen-curriculum.mjs"
|
|
58
56
|
},
|
|
59
57
|
"engines": {
|
|
@@ -17,6 +17,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
17
17
|
|
|
18
18
|
import { SCENARIOS, SCENARIO_IDS, byLevel } from '../src/core/presets.mjs'
|
|
19
19
|
import { LABS, LAB_IDS, hasLab, labAddressing, labDevices, labModels } from '../src/core/labs.mjs'
|
|
20
|
+
import { hasLabConfig } from '../src/core/labconfig.mjs'
|
|
20
21
|
import { imageNotesFor } from '../src/core/compat.mjs'
|
|
21
22
|
import { hasChecks, EXPECTATIONS } from '../src/core/acceptance.mjs'
|
|
22
23
|
import { curriculumFor } from '../src/core/curriculum.mjs'
|
|
@@ -103,14 +104,14 @@ function render(catalogue = SCENARIOS) {
|
|
|
103
104
|
out.push('', `## ${LEVEL_TITLES[level]}`, '')
|
|
104
105
|
for (const id of groups[level]) {
|
|
105
106
|
const s = catalogue[id]
|
|
106
|
-
out.push(`### ${id}
|
|
107
|
+
out.push(`### ${id} · ${s.title}`, '')
|
|
107
108
|
out.push(`- **英文名**:${s.titleEn}`)
|
|
108
109
|
out.push(`- **来源**:${s.source}`)
|
|
109
110
|
out.push(`- **设备**:${s.devices}`)
|
|
110
111
|
out.push(`- **目标**:${s.goal}`)
|
|
111
112
|
out.push('', '**配置顺序**', '')
|
|
112
113
|
s.steps.forEach((step, i) => {
|
|
113
|
-
const detail = step.detail ?
|
|
114
|
+
const detail = step.detail ? `:${step.detail}` : ''
|
|
114
115
|
out.push(`${i + 1}. **${step.do}**${detail}`)
|
|
115
116
|
if (step.verify) out.push(` - 验证:\`${step.verify}\``)
|
|
116
117
|
})
|
|
@@ -133,9 +134,12 @@ function render(catalogue = SCENARIOS) {
|
|
|
133
134
|
const notes = imageNotesFor(labModels(id))
|
|
134
135
|
for (const [model, note] of notes) out.push(`- 设备镜像:${model} ${note}`)
|
|
135
136
|
out.push('', `生成:\`ensp preset ${id} --topo ${id}.topo\``)
|
|
137
|
+
if (hasLabConfig(id)) {
|
|
138
|
+
out.push(`地址配置:\`ensp preset ${id} --config [目录]\``)
|
|
139
|
+
}
|
|
136
140
|
const verifyLine = hasChecks(id)
|
|
137
141
|
? `- 验收:\`ensp verify ${id} --topology ${id}.topo\`(${EXPECTATIONS[id].length} 条断言)`
|
|
138
|
-
: `- 验收:\`ensp verify ${id} --topology ${id}.topo
|
|
142
|
+
: `- 验收:\`ensp verify ${id} --topology ${id}.topo\`(无专门断言,只跑本场景的验证命令并报告 unverified)`
|
|
139
143
|
out.push(verifyLine)
|
|
140
144
|
}
|
|
141
145
|
|
|
@@ -12,11 +12,20 @@
|
|
|
12
12
|
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
13
13
|
import path from 'node:path'
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
import { resolveEnspRoot } from '../src/core/paths.mjs'
|
|
16
|
+
|
|
17
|
+
/** Locations to scan when `ENSP_EXAMPLES` says nothing; the install on this machine comes first. */
|
|
18
|
+
function defaultRoots() {
|
|
19
|
+
const installed = resolveEnspRoot()
|
|
20
|
+
const roots = installed ? [path.join(installed, 'examples')] : []
|
|
21
|
+
roots.push(
|
|
22
|
+
'C:/Program Files/Huawei/eNSP/examples',
|
|
23
|
+
'D:/Huawei/eNSP/examples',
|
|
24
|
+
'E:/Huawei/eNSP/examples',
|
|
25
|
+
'/opt/ensp/examples',
|
|
26
|
+
)
|
|
27
|
+
return roots
|
|
28
|
+
}
|
|
20
29
|
|
|
21
30
|
/** Recursively collect .topo paths. */
|
|
22
31
|
function findTopologies(root, out = []) {
|
|
@@ -103,7 +112,7 @@ function inspect(file) {
|
|
|
103
112
|
function main() {
|
|
104
113
|
const json = process.argv.includes('--json')
|
|
105
114
|
const roots = (process.env.ENSP_EXAMPLES ?? '').split(path.delimiter).filter(Boolean)
|
|
106
|
-
const searchRoots = roots.length ? roots :
|
|
115
|
+
const searchRoots = roots.length ? roots : defaultRoots()
|
|
107
116
|
|
|
108
117
|
const files = searchRoots.flatMap((r) => findTopologies(r))
|
|
109
118
|
if (!files.length) {
|
package/src/cli/ensp.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* @module dsh-ensp/cli
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { readFile, access } from 'node:fs/promises'
|
|
19
|
+
import { readFile, access, mkdir, writeFile } from 'node:fs/promises'
|
|
20
20
|
import { constants } from 'node:fs'
|
|
21
21
|
import path from 'node:path'
|
|
22
22
|
import process from 'node:process'
|
|
@@ -28,14 +28,15 @@ import {
|
|
|
28
28
|
startAllDevices,
|
|
29
29
|
stopAllDevices,
|
|
30
30
|
launchEnsp,
|
|
31
|
+
showEnsp,
|
|
31
32
|
waitForConsole,
|
|
32
33
|
} from '../core/launcher.mjs'
|
|
33
34
|
import { calibrateAnchors, inspectAnchors, DEFAULT_ANCHORS } from '../core/calibrate.mjs'
|
|
34
35
|
import { doctor, formatDoctor, modelsOf } from '../core/doctor.mjs'
|
|
35
36
|
import { runAcceptance, formatAcceptance, hasChecks, planFor } from '../core/acceptance.mjs'
|
|
36
37
|
import { pushConfig } from '../core/provision.mjs'
|
|
38
|
+
import { writeReport } from '../core/report.mjs'
|
|
37
39
|
import { SCENARIOS, SCENARIO_IDS, byLevel, renderScenario, searchScenarios } from '../core/presets.mjs'
|
|
38
|
-
import { knowledgeFor, renderKnowledge, searchKnowledge } from '../core/knowledge.mjs'
|
|
39
40
|
import { curriculumFor, renderCurriculum, searchCurriculum } from '../core/curriculum.mjs'
|
|
40
41
|
import {
|
|
41
42
|
LAB_IDS,
|
|
@@ -44,7 +45,8 @@ import {
|
|
|
44
45
|
labAddressing,
|
|
45
46
|
labDevices,
|
|
46
47
|
} from '../core/labs.mjs'
|
|
47
|
-
import {
|
|
48
|
+
import { countLabConfigs, hasLabConfig, labConfigScript, labConfigs } from '../core/labconfig.mjs'
|
|
49
|
+
import { checkTopologySupport, pluginMediaPlan, pluginModels, installPluginMedia } from '../core/compat.mjs'
|
|
48
50
|
import { writeTopology } from '../core/topology.mjs'
|
|
49
51
|
|
|
50
52
|
const C = {
|
|
@@ -64,9 +66,12 @@ ${C.bold('USAGE')}
|
|
|
64
66
|
ensp status [--topology <topology.topo>]
|
|
65
67
|
ensp doctor [--topology <topology.topo>] [--json] [--shallow]
|
|
66
68
|
ensp verify <scenario-id> --topology <topology.topo> [--json] [--wait MS]
|
|
69
|
+
ensp report <scenario-id> [--topology <topology.topo>] [--out <file.docx>] [--json]
|
|
67
70
|
ensp probe [--json]
|
|
71
|
+
ensp show [--json] (bring the eNSP window back to the front)
|
|
72
|
+
ensp images [--json] [<model>] [--install <image>]
|
|
68
73
|
ensp stop [--topology <topology.topo>]
|
|
69
|
-
ensp preset [<id>] [--search <keyword>] [--syllabus <keyword>] [--topo <out.topo>] [--json]
|
|
74
|
+
ensp preset [<id>] [--search <keyword>] [--syllabus <keyword>] [--topo <out.topo>] [--config] [--json]
|
|
70
75
|
ensp mcp (start the MCP server on stdio)
|
|
71
76
|
|
|
72
77
|
${C.bold('OPTIONS')}
|
|
@@ -76,8 +81,10 @@ ${C.bold('OPTIONS')}
|
|
|
76
81
|
--device NAME apply only to one device
|
|
77
82
|
--timeout MS per-device readiness timeout (default 240000)
|
|
78
83
|
--wait MS verify: how long a failing assertion may keep being retried (default 60000)
|
|
84
|
+
--out PATH report: where to write the .docx (default <scenario>-report.docx)
|
|
85
|
+
--install PATH images: put this device image where this machine's eNSP looks for it
|
|
79
86
|
--syllabus KW search the vendor manual chapter index (goals and verification commands)
|
|
80
|
-
--
|
|
87
|
+
--config [DIR] print the scenario's addressing plan as commands, or write one .cfg per device into DIR
|
|
81
88
|
--topo PATH write the scenario's topology blueprint to a .topo file
|
|
82
89
|
--topology PATH the .topo file that is running (doctor, verify)
|
|
83
90
|
--exe PATH full path to eNSP_Client.exe (default: auto-detect via registry and install roots)
|
|
@@ -90,10 +97,14 @@ ${C.bold('EXAMPLES')}
|
|
|
90
97
|
ensp up lab.topo
|
|
91
98
|
ensp doctor --topology lab.topo
|
|
92
99
|
ensp verify ospf-single --topology lab.topo
|
|
100
|
+
ensp report ospf-single --topology lab.topo --out ospf-single.docx
|
|
93
101
|
ensp apply ./configs --topology lab.topo --save
|
|
94
102
|
ensp status --topology lab.topo
|
|
95
103
|
ensp up lab.topo --exe "C:/Program Files/Huawei/eNSP/eNSP_Client.exe"
|
|
96
104
|
ensp probe
|
|
105
|
+
ensp show
|
|
106
|
+
ensp images
|
|
107
|
+
ensp images USG6000V --install D:/downloads/vfw_usg.vdi
|
|
97
108
|
ensp preset --search ospf
|
|
98
109
|
ensp preset --syllabus "MPLS"
|
|
99
110
|
ensp preset ospf-single
|
|
@@ -343,23 +354,6 @@ async function cmdPreset({ positional, flags }) {
|
|
|
343
354
|
return 0
|
|
344
355
|
}
|
|
345
356
|
|
|
346
|
-
if (flags.knowledge) {
|
|
347
|
-
const hits = searchKnowledge(String(flags.knowledge))
|
|
348
|
-
if (flags.json) {
|
|
349
|
-
console.log(JSON.stringify(hits, null, 2))
|
|
350
|
-
return hits.length ? 0 : 1
|
|
351
|
-
}
|
|
352
|
-
if (!hits.length) {
|
|
353
|
-
console.log(C.yellow('no knowledge point matched') + ` "${flags.knowledge}"`)
|
|
354
|
-
return 1
|
|
355
|
-
}
|
|
356
|
-
for (const h of hits) {
|
|
357
|
-
console.log(`${C.bold('#' + h.n)} ${C.dim('[' + h.topic + ']')} ${h.q}`)
|
|
358
|
-
console.log(` 答: ${h.a}`)
|
|
359
|
-
}
|
|
360
|
-
return 0
|
|
361
|
-
}
|
|
362
|
-
|
|
363
357
|
if (flags.search) {
|
|
364
358
|
const hits = searchScenarios(String(flags.search))
|
|
365
359
|
if (flags.json) {
|
|
@@ -398,6 +392,50 @@ async function cmdPreset({ positional, flags }) {
|
|
|
398
392
|
}
|
|
399
393
|
|
|
400
394
|
try {
|
|
395
|
+
if (flags.config) {
|
|
396
|
+
if (!hasLabConfig(id)) {
|
|
397
|
+
console.error(
|
|
398
|
+
C.red('error:') +
|
|
399
|
+
` scenario "${id}" has no addressing plan to turn into a script; ${countLabConfigs()} of ${SCENARIO_IDS.length} have one`,
|
|
400
|
+
)
|
|
401
|
+
return 2
|
|
402
|
+
}
|
|
403
|
+
const script = labConfigScript(id)
|
|
404
|
+
if (flags.config !== true) {
|
|
405
|
+
// A path was given, so write the one-file-per-device directory that
|
|
406
|
+
// `ensp apply` reads instead of printing a script the reader has to
|
|
407
|
+
// split by hand.
|
|
408
|
+
const dir = path.resolve(String(flags.config))
|
|
409
|
+
await mkdir(dir, { recursive: true })
|
|
410
|
+
const written = []
|
|
411
|
+
for (const entry of labConfigs(id)) {
|
|
412
|
+
const file = path.join(dir, entry.device + '.cfg')
|
|
413
|
+
await writeFile(file, entry.script + '\n', 'utf8')
|
|
414
|
+
written.push(file)
|
|
415
|
+
}
|
|
416
|
+
if (flags.json) {
|
|
417
|
+
console.log(JSON.stringify({ scenario: id, directory: dir, files: written }, null, 2))
|
|
418
|
+
return 0
|
|
419
|
+
}
|
|
420
|
+
for (const file of written) console.log(`${C.green('wrote')} ${file}`)
|
|
421
|
+
console.log(
|
|
422
|
+
C.dim(`\n# 只有地址规划 · 路由与策略仍按步骤自己配\n# 下一步: ensp apply ${dir} --topology <lab.topo>`),
|
|
423
|
+
)
|
|
424
|
+
return 0
|
|
425
|
+
}
|
|
426
|
+
if (flags.json) {
|
|
427
|
+
console.log(JSON.stringify({ scenario: id, script }, null, 2))
|
|
428
|
+
return 0
|
|
429
|
+
}
|
|
430
|
+
console.log(script)
|
|
431
|
+
console.log(
|
|
432
|
+
C.dim(
|
|
433
|
+
`\n# 只覆盖地址规划,路由与策略仍按步骤自己配 · 给 --config 一个目录即可写成每台设备一个 .cfg`,
|
|
434
|
+
),
|
|
435
|
+
)
|
|
436
|
+
return 0
|
|
437
|
+
}
|
|
438
|
+
|
|
401
439
|
if (flags.topo) {
|
|
402
440
|
if (!hasLab(id)) {
|
|
403
441
|
console.error(
|
|
@@ -432,7 +470,6 @@ async function cmdPreset({ positional, flags }) {
|
|
|
432
470
|
}
|
|
433
471
|
|
|
434
472
|
const text = renderScenario(id)
|
|
435
|
-
const points = knowledgeFor(id)
|
|
436
473
|
const chapters = curriculumFor(id).map((c) => ({
|
|
437
474
|
level: c.level,
|
|
438
475
|
title: c.title,
|
|
@@ -441,16 +478,17 @@ async function cmdPreset({ positional, flags }) {
|
|
|
441
478
|
}))
|
|
442
479
|
console.log(
|
|
443
480
|
flags.json
|
|
444
|
-
? JSON.stringify({ ...SCENARIOS[id],
|
|
481
|
+
? JSON.stringify({ ...SCENARIOS[id], curriculum: chapters }, null, 2)
|
|
445
482
|
: text,
|
|
446
483
|
)
|
|
447
484
|
const taught = renderCurriculum(id)
|
|
448
485
|
if (taught) console.log(taught)
|
|
449
|
-
const knowledge = renderKnowledge(id)
|
|
450
|
-
if (knowledge) console.log(knowledge)
|
|
451
486
|
if (hasLab(id)) {
|
|
452
487
|
console.log(C.dim(`\n可生成拓扑: ensp preset ${id} --topo ${id}.topo`))
|
|
453
488
|
}
|
|
489
|
+
if (hasLabConfig(id)) {
|
|
490
|
+
console.log(C.dim(`地址规划: ensp preset ${id} --config`))
|
|
491
|
+
}
|
|
454
492
|
return 0
|
|
455
493
|
} catch (error) {
|
|
456
494
|
console.error(C.red('error:') + ' ' + error.message)
|
|
@@ -497,6 +535,43 @@ async function cmdProbe({ flags }) {
|
|
|
497
535
|
return r.resolved?.method === 'measured' ? 0 : 1
|
|
498
536
|
}
|
|
499
537
|
|
|
538
|
+
/**
|
|
539
|
+
* `ensp show` (alias `ensp front`)
|
|
540
|
+
*
|
|
541
|
+
* Bring eNSP back out of the taskbar and in front, so the lab can be watched while
|
|
542
|
+
* it is being configured. Booting minimises the window on purpose, which is fine
|
|
543
|
+
* until someone wants to look at it.
|
|
544
|
+
*/
|
|
545
|
+
async function cmdShow({ flags }) {
|
|
546
|
+
const pid = enspPid()
|
|
547
|
+
if (!pid) {
|
|
548
|
+
console.error(C.red('error:') + ' eNSP is not running')
|
|
549
|
+
return 1
|
|
550
|
+
}
|
|
551
|
+
const hwnd = locateWindow(pid)
|
|
552
|
+
if (!hwnd) {
|
|
553
|
+
console.error(C.red('error:') + ' eNSP window not found')
|
|
554
|
+
return 1
|
|
555
|
+
}
|
|
556
|
+
const r = showEnsp(hwnd)
|
|
557
|
+
if (flags.json) {
|
|
558
|
+
console.log(JSON.stringify({ pid, hwnd, ...r }, null, 2))
|
|
559
|
+
return r.raised ? 0 : 1
|
|
560
|
+
}
|
|
561
|
+
console.log(`${C.bold('window')} ${hwnd} (pid ${pid})`)
|
|
562
|
+
console.log(r.restored ? `${C.green('restored')} from the taskbar` : C.dim('was not minimised'))
|
|
563
|
+
if (r.raised) {
|
|
564
|
+
console.log(`${C.green('in front')}`)
|
|
565
|
+
return 0
|
|
566
|
+
}
|
|
567
|
+
console.error(
|
|
568
|
+
C.yellow('warning:') +
|
|
569
|
+
' the window was restored but another window still holds the foreground. Windows does not let another ' +
|
|
570
|
+
'process raise a foreign window, so click the taskbar button once.',
|
|
571
|
+
)
|
|
572
|
+
return 1
|
|
573
|
+
}
|
|
574
|
+
|
|
500
575
|
/**
|
|
501
576
|
* `ensp doctor [--topology t.topo] [--json] [--shallow]`
|
|
502
577
|
*
|
|
@@ -504,6 +579,87 @@ async function cmdProbe({ flags }) {
|
|
|
504
579
|
* carries its own fix, because the failure modes here (missing image, covered
|
|
505
580
|
* window, dead input queue) all look the same from the outside.
|
|
506
581
|
*/
|
|
582
|
+
/**
|
|
583
|
+
* Report which plugin device images this installation has, and install one.
|
|
584
|
+
*
|
|
585
|
+
* The destination is never guessed here: `pluginMediaPlan` reads it out of the
|
|
586
|
+
* model's own VirtualBox template, so the same command works on a machine that
|
|
587
|
+
* installed eNSP somewhere else.
|
|
588
|
+
*/
|
|
589
|
+
async function cmdImages({ positional, flags }) {
|
|
590
|
+
const model = positional[0]
|
|
591
|
+
|
|
592
|
+
if (flags.install) {
|
|
593
|
+
if (!model) {
|
|
594
|
+
console.error(C.red('error:') + ' usage: ensp images <model> --install <image-file>')
|
|
595
|
+
return 2
|
|
596
|
+
}
|
|
597
|
+
const source = path.resolve(String(flags.install))
|
|
598
|
+
if (!(await exists(source))) {
|
|
599
|
+
console.error(C.red('error:') + ` no such file: ${source}`)
|
|
600
|
+
return 2
|
|
601
|
+
}
|
|
602
|
+
let result
|
|
603
|
+
try {
|
|
604
|
+
result = installPluginMedia(model, source, { overwrite: Boolean(flags.overwrite) })
|
|
605
|
+
} catch (error) {
|
|
606
|
+
console.error(C.red('error:') + ' ' + error.message)
|
|
607
|
+
return 1
|
|
608
|
+
}
|
|
609
|
+
if (flags.json) {
|
|
610
|
+
console.log(JSON.stringify(result, null, 2))
|
|
611
|
+
return 0
|
|
612
|
+
}
|
|
613
|
+
if (!result.bytes) {
|
|
614
|
+
console.log(`${C.bold(model)} already has its image at ${result.target}`)
|
|
615
|
+
console.log(C.dim('pass --overwrite to replace it'))
|
|
616
|
+
return 0
|
|
617
|
+
}
|
|
618
|
+
console.log(`${C.green('installed')} ${model} → ${result.target} (${result.bytes} bytes)`)
|
|
619
|
+
if (result.iniUpdated) console.log(C.dim('ImgFile updated in the plugin system.ini'))
|
|
620
|
+
console.log(C.dim('restart eNSP if it is open, then run ensp doctor --topology <file>'))
|
|
621
|
+
return 0
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const models = model ? [model] : pluginModels()
|
|
625
|
+
if (!models.length) {
|
|
626
|
+
console.error(C.red('error:') + ' this eNSP installation declares no plugin devices')
|
|
627
|
+
return 1
|
|
628
|
+
}
|
|
629
|
+
const plans = models.map((name) => pluginMediaPlan(name))
|
|
630
|
+
|
|
631
|
+
if (flags.json) {
|
|
632
|
+
console.log(JSON.stringify(plans, null, 2))
|
|
633
|
+
return plans.every((p) => p.ready) ? 0 : 1
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
for (const plan of plans) {
|
|
637
|
+
if (!plan.plugin) {
|
|
638
|
+
console.log(`${C.bold(plan.model)} ${C.dim('not a plugin device in this installation')}`)
|
|
639
|
+
continue
|
|
640
|
+
}
|
|
641
|
+
if (plan.ready) {
|
|
642
|
+
console.log(`${C.green('ready')} ${plan.model} ${C.dim(plan.media.find((m) => m.present).relative)}`)
|
|
643
|
+
continue
|
|
644
|
+
}
|
|
645
|
+
console.log(`${C.yellow('missing')} ${plan.model} ${C.dim(plan.target ?? 'no template')}`)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const missing = plans.filter((p) => p.plugin && !p.ready)
|
|
649
|
+
if (missing.length) {
|
|
650
|
+
console.log(
|
|
651
|
+
'\n' +
|
|
652
|
+
C.dim(
|
|
653
|
+
'Install one with: ensp images <model> --install <image-file>\n' +
|
|
654
|
+
'Or reinstall eNSP and tick the device component. eNSP opens a topology that ' +
|
|
655
|
+
'uses these models, renders it, then quits by itself.',
|
|
656
|
+
),
|
|
657
|
+
)
|
|
658
|
+
}
|
|
659
|
+
return missing.length ? 1 : 0
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** `ensp doctor [--topology t.topo] [--json] [--shallow]` */
|
|
507
663
|
async function cmdDoctor({ positional, flags }) {
|
|
508
664
|
let models
|
|
509
665
|
if (flags.topology || positional[0]) {
|
|
@@ -529,6 +685,20 @@ async function cmdDoctor({ positional, flags }) {
|
|
|
529
685
|
return report.ok ? 0 : 1
|
|
530
686
|
}
|
|
531
687
|
|
|
688
|
+
/**
|
|
689
|
+
* Exit code for an acceptance report.
|
|
690
|
+
*
|
|
691
|
+
* 0 means the checks that carry an assertion all passed and at least one of them
|
|
692
|
+
* existed. 1 means something contradicted the expected state. 3 means the lab was
|
|
693
|
+
* reachable but nothing was asserted against it, so the run proved only that the
|
|
694
|
+
* commands were accepted.
|
|
695
|
+
*/
|
|
696
|
+
function exitCodeFor(report) {
|
|
697
|
+
if (report.ok === true) return 0
|
|
698
|
+
if (report.ok === false) return 1
|
|
699
|
+
return 3
|
|
700
|
+
}
|
|
701
|
+
|
|
532
702
|
/**
|
|
533
703
|
* `ensp verify <scenario-id> --topology t.topo [--json]`
|
|
534
704
|
*
|
|
@@ -567,10 +737,57 @@ async function cmdVerify({ positional, flags }) {
|
|
|
567
737
|
})
|
|
568
738
|
if (flags.json) {
|
|
569
739
|
console.log(JSON.stringify(report, null, 2))
|
|
570
|
-
return report
|
|
740
|
+
return exitCodeFor(report)
|
|
571
741
|
}
|
|
572
742
|
console.log(formatAcceptance(report))
|
|
573
|
-
return report
|
|
743
|
+
return exitCodeFor(report)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async function cmdReport({ positional, flags }) {
|
|
747
|
+
const id = positional[0]
|
|
748
|
+
if (!id) {
|
|
749
|
+
console.error(C.red('error:') + ' usage: ensp report <scenario-id> [--topology <topology.topo>] [--out <file.docx>]')
|
|
750
|
+
return 2
|
|
751
|
+
}
|
|
752
|
+
if (!SCENARIOS[id]) {
|
|
753
|
+
console.error(C.red('error:') + ` unknown scenario "${id}"`)
|
|
754
|
+
return 2
|
|
755
|
+
}
|
|
756
|
+
const out = flags.out ? String(flags.out) : undefined
|
|
757
|
+
if (!flags.json) {
|
|
758
|
+
console.log(C.dim(`collecting evidence for ${id}${flags.topology ? ` from ${flags.topology}` : ' (blueprint ports)'}`))
|
|
759
|
+
}
|
|
760
|
+
const result = await writeReport({
|
|
761
|
+
id,
|
|
762
|
+
topology: flags.topology ? String(flags.topology) : undefined,
|
|
763
|
+
out,
|
|
764
|
+
timeoutMs: flags.timeout ? Number(flags.timeout) : undefined,
|
|
765
|
+
waitMs: flags.wait ? Number(flags.wait) : undefined,
|
|
766
|
+
onProgress: flags.json
|
|
767
|
+
? undefined
|
|
768
|
+
: (e) => {
|
|
769
|
+
if (e.phase === 'check') process.stderr.write(C.dim(` checking ${e.id} on ${e.device}\n`))
|
|
770
|
+
else if (e.phase === 'retry')
|
|
771
|
+
process.stderr.write(C.dim(` waiting ${Math.round(e.waited / 1000)}s for ${e.id} on ${e.device}\n`))
|
|
772
|
+
},
|
|
773
|
+
})
|
|
774
|
+
const { verdict } = result.evidence
|
|
775
|
+
if (flags.json) {
|
|
776
|
+
console.log(
|
|
777
|
+
JSON.stringify(
|
|
778
|
+
{ path: result.path, bytes: result.bytes, scenario: result.evidence.id, verdict },
|
|
779
|
+
null,
|
|
780
|
+
2,
|
|
781
|
+
),
|
|
782
|
+
)
|
|
783
|
+
} else {
|
|
784
|
+
console.log(`wrote ${result.path} (${result.bytes} bytes)`)
|
|
785
|
+
console.log(
|
|
786
|
+
`verdict: ${verdict.ok === true ? 'passed' : verdict.ok === false ? 'failed' : 'unverified'} ` +
|
|
787
|
+
`(${verdict.passed} passed, ${verdict.failed} failed, ${verdict.unverified} unverified of ${verdict.total})`,
|
|
788
|
+
)
|
|
789
|
+
}
|
|
790
|
+
return verdict.ok === false ? 1 : 0
|
|
574
791
|
}
|
|
575
792
|
|
|
576
793
|
// ------------------------------------------------------------------------- main
|
|
@@ -598,8 +815,15 @@ async function main() {
|
|
|
598
815
|
return cmdDoctor({ positional, flags })
|
|
599
816
|
case 'verify':
|
|
600
817
|
return cmdVerify({ positional, flags })
|
|
818
|
+
case 'report':
|
|
819
|
+
return cmdReport({ positional, flags })
|
|
601
820
|
case 'stop':
|
|
602
821
|
return cmdUp({ positional, flags: { ...flags, stop: true } })
|
|
822
|
+
case 'show':
|
|
823
|
+
case 'front':
|
|
824
|
+
return cmdShow({ flags })
|
|
825
|
+
case 'images':
|
|
826
|
+
return cmdImages({ positional, flags })
|
|
603
827
|
case 'preset':
|
|
604
828
|
return cmdPreset({ positional, flags })
|
|
605
829
|
case 'mcp':
|