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.
@@ -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
  | 项 | 值 |
@@ -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).
@@ -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.6.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",
@@ -15,6 +15,7 @@
15
15
  ],
16
16
  "type": "module",
17
17
  "license": "GPL-3.0-or-later",
18
+ "icon": "./assets/icon.webp",
18
19
  "main": "./src/dsh/index.mjs",
19
20
  "exports": {
20
21
  ".": "./src/dsh/index.mjs",
@@ -38,20 +39,19 @@
38
39
  "files": [
39
40
  "src",
40
41
  "locale/*.json",
42
+ "assets/icon.webp",
41
43
  "docs",
42
44
  "scripts",
43
45
  "cordis.patch.yml",
44
- "screenshots.json",
45
46
  "README.md",
46
47
  "README.en.md",
47
48
  "LICENSE"
48
49
  ],
49
50
  "scripts": {
50
- "test": "node --test tests/core.test.mjs",
51
+ "test": "node --test tests/core.test.mjs tests/ooxml.test.mjs",
51
52
  "probe": "node src/cli/ensp.mjs probe",
52
53
  "docs": "node scripts/gen-scenarios-doc.mjs",
53
54
  "docs:check": "node scripts/gen-scenarios-doc.mjs --check",
54
- "knowledge": "node scripts/gen-knowledge.mjs",
55
55
  "curriculum": "node scripts/gen-curriculum.mjs"
56
56
  },
57
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} — ${s.title}`, '')
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 ? ` — ${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
- const DEFAULT_ROOTS = [
16
- 'E:/Huawei/eNSP/examples',
17
- 'C:/Program Files/Huawei/eNSP/examples',
18
- '/opt/ensp/examples',
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 : DEFAULT_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 { checkTopologySupport } from '../core/compat.mjs'
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
- --knowledge KW search the term index (short answers filed by topic)
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], knowledge: points, curriculum: chapters }, null, 2)
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.ok ? 0 : 1
740
+ return exitCodeFor(report)
571
741
  }
572
742
  console.log(formatAcceptance(report))
573
- return report.ok ? 0 : 1
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':