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.
@@ -6,9 +6,9 @@
6
6
  *
7
7
  * 1. an explicit override (CLI flag, plugin config, or function argument)
8
8
  * 2. `DSH_ENSP_EXE` / `ENSP_EXE` / `DSH_VBOX`
9
- * 3. the Windows install registry
9
+ * 3. the Windows install registry, including the Inno Setup uninstall keys
10
10
  * 4. the usual install roots
11
- * 5. `where` on PATH (VBoxManage only)
11
+ * 5. `where` on PATH
12
12
  *
13
13
  * Every candidate that was tried is reported on failure, so a wrong override
14
14
  * produces a message that says what was looked at instead of a bare ENOENT.
@@ -17,11 +17,28 @@ import fs from 'node:fs'
17
17
  import path from 'node:path'
18
18
  import { spawnSync } from 'node:child_process'
19
19
 
20
- /** Registry keys an installer may have written the install root to. */
20
+ /**
21
+ * Registry keys an installer may have written the install root to.
22
+ *
23
+ * The Huawei keys are what an eNSP build writes for itself. The Uninstall keys
24
+ * are what Inno Setup writes, and they are the ones that exist on a normal
25
+ * installation: `eNSP` carries `InstallPath`, and the GUID key carries
26
+ * `InstallLocation`. Reading both is what makes an installation outside the
27
+ * usual four roots findable at all.
28
+ */
21
29
  const ENSP_REGISTRY = [
22
30
  ['HKLM\\SOFTWARE\\Huawei\\eNSP', 'InstallPath'],
23
31
  ['HKLM\\SOFTWARE\\WOW6432Node\\Huawei\\eNSP', 'InstallPath'],
24
32
  ['HKLM\\SOFTWARE\\Huawei\\eNSP', 'InstallDir'],
33
+ ['HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\eNSP', 'InstallPath'],
34
+ ['HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\eNSP', 'InstallFullPath'],
35
+ ['HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\eNSP', 'InstallPath'],
36
+ ]
37
+
38
+ /** VirtualBox writes its own directory here, whatever drive it landed on. */
39
+ const VBOX_REGISTRY = [
40
+ ['HKLM\\SOFTWARE\\Oracle\\VirtualBox', 'InstallDir'],
41
+ ['HKLM\\SOFTWARE\\WOW6432Node\\Oracle\\VirtualBox', 'InstallDir'],
25
42
  ]
26
43
 
27
44
  /** Install roots seen on real machines, most common first. */
@@ -52,13 +69,28 @@ function isFile(candidate) {
52
69
  }
53
70
  }
54
71
 
72
+ /**
73
+ * Registry reads are cached for the life of the process.
74
+ *
75
+ * Each `reg query` is a process spawn at roughly 100 ms, and the install paths
76
+ * cannot change while the tool is running, so repeating them made every path
77
+ * lookup cost a second for nothing.
78
+ */
79
+ const registryCache = new Map()
80
+
55
81
  /** Read one string value out of the registry, or null. */
56
82
  function registryValue(key, name) {
57
83
  if (!isWindows) return null
84
+ const cacheKey = `${key}\u0000${name}`
85
+ if (registryCache.has(cacheKey)) return registryCache.get(cacheKey)
58
86
  const r = spawnSync('reg', ['query', key, '/v', name], { encoding: 'utf8', windowsHide: true })
59
- if (r.status !== 0 || !r.stdout) return null
60
- const m = new RegExp(`${name}\\s+REG_\\w+\\s+(.+)`).exec(r.stdout)
61
- return m ? m[1].trim() : null
87
+ let value = null
88
+ if (r.status === 0 && r.stdout) {
89
+ const m = new RegExp(`${name}\\s+REG_\\w+\\s+(.+)`).exec(r.stdout)
90
+ value = m ? m[1].trim() : null
91
+ }
92
+ registryCache.set(cacheKey, value)
93
+ return value
62
94
  }
63
95
 
64
96
  /** Expand `%VAR%` and strip surrounding quotes from a registry value. */
@@ -83,8 +115,10 @@ export function enspCandidates(override) {
83
115
  if (v) out.push({ path: v, source: name })
84
116
  }
85
117
  for (const [key, name] of ENSP_REGISTRY) {
86
- const root = cleanRoot(registryValue(key, name))
87
- if (root) out.push({ path: path.join(root, 'eNSP_Client.exe'), source: `${key}\\${name}` })
118
+ const value = cleanRoot(registryValue(key, name))
119
+ if (!value) continue
120
+ const exe = /\.exe$/i.test(value) ? value : path.join(value, 'eNSP_Client.exe')
121
+ out.push({ path: exe, source: `${key}\\${name}` })
88
122
  }
89
123
  for (const root of ENSP_ROOTS) out.push({ path: path.join(root, 'eNSP_Client.exe'), source: 'install root' })
90
124
  return out
@@ -95,15 +129,30 @@ export function vboxCandidates(override) {
95
129
  const out = []
96
130
  if (override) out.push({ path: override, source: 'override' })
97
131
  if (process.env.DSH_VBOX) out.push({ path: process.env.DSH_VBOX, source: 'DSH_VBOX' })
132
+ // Registry before PATH: VirtualBox always writes InstallDir, and a PATH entry
133
+ // can belong to a second or stale copy.
134
+ for (const [key, name] of VBOX_REGISTRY) {
135
+ const dir = cleanRoot(registryValue(key, name))
136
+ if (dir) out.push({ path: path.join(dir, 'VBoxManage.exe'), source: `${key}\\${name}` })
137
+ }
98
138
  if (isWindows) {
99
- const r = spawnSync('where', ['VBoxManage.exe'], { encoding: 'utf8', windowsHide: true })
100
- const first = (r.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0]
139
+ const first = firstOnPath('VBoxManage.exe')
101
140
  if (first) out.push({ path: first, source: 'PATH' })
102
141
  }
103
142
  for (const p of VBOX_PATHS) out.push({ path: p, source: 'install root' })
104
143
  return out
105
144
  }
106
145
 
146
+ /** First hit from `where`, cached: it is a process spawn and PATH does not move. */
147
+ const pathCache = new Map()
148
+ function firstOnPath(program) {
149
+ if (pathCache.has(program)) return pathCache.get(program)
150
+ const r = spawnSync('where', [program], { encoding: 'utf8', windowsHide: true })
151
+ const first = (r.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] ?? null
152
+ pathCache.set(program, first)
153
+ return first
154
+ }
155
+
107
156
  /** First existing candidate, or null. */
108
157
  function firstExisting(candidates) {
109
158
  for (const c of candidates) if (isFile(c.path)) return c
@@ -582,11 +582,15 @@ export const SCENARIOS = {
582
582
  devices: '2 台路由器(串口)',
583
583
  goal: '串口封装、PPP 认证、PPPoE 拨号',
584
584
  steps: [
585
- { do: '设封装类型', detail: 'HDLC 为默认;PPP 支持认证与多链路', verify: 'display interface Serial' },
586
- { do: '配置认证', detail: '一端 authenticator,另一端 pap/chap 用户名密码', verify: 'display ppp interface' },
587
- { do: 'PPPoE', detail: '服务端建 VT 与地址池,客户端 dialer 拨号', verify: 'display pppoe-client session summary' },
585
+ { do: '设封装类型', detail: '串口默认是 PPP,HDLC 要显式切换;PPP 支持认证与多链路', verify: 'display interface Serial0/0/1' },
586
+ { do: '配置认证', detail: '一端 authenticator,另一端 pap/chap 用户名密码', verify: 'display current-configuration interface Serial0/0/0' },
587
+ { do: 'PPPoE', detail: '服务端建 VT 与地址池,客户端 dialer 拨号', verify: 'display ip pool' },
588
+ ],
589
+ pitfalls: [
590
+ '串口上默认封装是 PPP,不是 HDLC,`display interface` 里的 Link layer protocol 会直接说明当前是哪种',
591
+ 'PPP 认证两端 pap/chap 不一致会反复协商失败',
592
+ 'PPPoE 服务端要在物理口上 `pppoe-server bind virtual-template`,地址池挂在 VT 上',
588
593
  ],
589
- pitfalls: ['PPP 认证两端 pap/chap 不一致会反复协商失败', '串口需先确认物理 up 且时钟配置正确'],
590
594
  },
591
595
 
592
596
  'ipv6': {
@@ -1038,7 +1042,7 @@ export function renderScenario(id) {
1038
1042
  '',
1039
1043
  '## 配置顺序(只给方向,具体命令按设备补全)',
1040
1044
  ...s.steps.map((step, i) => {
1041
- const detail = step.detail ? ` — ${step.detail}` : ''
1045
+ const detail = step.detail ? `:${step.detail}` : ''
1042
1046
  const verify = step.verify ? `\n 验证: ${step.verify}` : ''
1043
1047
  return `${i + 1}. ${step.do}${detail}${verify}`
1044
1048
  }),
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Turns a finished lab into a Word delivery report.
3
+ *
4
+ * The report is evidence, not narration. Every command in it was applied to a
5
+ * live device in this run, every verification output is the text the device
6
+ * printed, and the verdict at the top is the same three-state verdict
7
+ * `ensp verify` gives. A lab whose checks had no assertion is reported as
8
+ * unverified here too, so the document cannot claim more than the run proved.
9
+ *
10
+ * Chapter order follows what a course deliverable is expected to contain:
11
+ * purpose, topology and addressing, configuration, verification, problems and
12
+ * conclusion, appendix.
13
+ *
14
+ * @module core/report
15
+ */
16
+
17
+ import { writeFileSync } from 'node:fs'
18
+ import { basename } from 'node:path'
19
+
20
+ import { buildDocx } from './docx.mjs'
21
+ import { consolePorts, runAcceptance } from './acceptance.mjs'
22
+ import { buildScenarioTopology, hasLab, labAddressing } from './labs.mjs'
23
+ import { SCENARIOS } from './presets.mjs'
24
+ import { queryDevice } from './provision.mjs'
25
+ import { readTopology } from './topology.mjs'
26
+
27
+ /** Kept short so one device's config cannot push a chapter onto its own page. */
28
+ const MAX_CONFIG_LINES = 400
29
+
30
+ /** How many verification commands to list in the appendix table. */
31
+ const APPENDIX_ROWS = 60
32
+
33
+ function today(now = new Date()) {
34
+ const pad = (n) => String(n).padStart(2, '0')
35
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
36
+ }
37
+
38
+ /**
39
+ * Ask every device for its running configuration.
40
+ *
41
+ * `display current-configuration` is used rather than the .cfg files that were
42
+ * applied: the files say what was meant, the device says what took effect, and
43
+ * a report is worth more when those differ.
44
+ */
45
+ async function collectConfigs(devices, query, timeoutMs) {
46
+ const configs = []
47
+ for (const device of devices) {
48
+ const port = device.comPort
49
+ if (!port) {
50
+ configs.push({ device: device.name, text: '', note: '没有控制台端口,未读取配置' })
51
+ continue
52
+ }
53
+ const res = await query({ port, command: 'display current-configuration', timeoutMs })
54
+ const output = (res?.output || '').trim()
55
+ if (!res?.ok || !output) {
56
+ configs.push({
57
+ device: device.name,
58
+ text: '',
59
+ note: res?.ok === false ? `读取失败:${res.error || '控制台无响应'}` : '设备无配置',
60
+ })
61
+ continue
62
+ }
63
+ const lines = output.split('\n')
64
+ configs.push({
65
+ device: device.name,
66
+ text: lines.slice(0, MAX_CONFIG_LINES).join('\n'),
67
+ note: lines.length > MAX_CONFIG_LINES ? `配置超过 ${MAX_CONFIG_LINES} 行,已截断` : '',
68
+ })
69
+ }
70
+ return configs
71
+ }
72
+
73
+ /**
74
+ * Run the lab's acceptance checks and keep the full output of each command.
75
+ *
76
+ * `runAcceptance` keeps a 400 character snippet because it is formatted for a
77
+ * terminal. A document wants the whole table, so this wraps its `query` and
78
+ * records the last answer per command, which after the convergence retry is the
79
+ * converged one.
80
+ */
81
+ async function collectChecks(id, { topology, ports, query, timeoutMs, waitMs, intervalMs, onProgress }) {
82
+ // Key captured output by device name, not by port: the report prints device
83
+ // names, and two devices can be asked the same question.
84
+ const byPort = new Map()
85
+ if (ports) {
86
+ for (const [name, port] of ports) byPort.set(port, name)
87
+ } else if (topology) {
88
+ const parsed = await readTopology(topology)
89
+ for (const [name, port] of consolePorts(parsed)) byPort.set(port, name)
90
+ }
91
+ const captured = new Map()
92
+ const recording = async (args) => {
93
+ const res = await query(args)
94
+ const name = byPort.get(args.port) ?? String(args.port)
95
+ captured.set(`${name}|${args.command}`, res?.output || '')
96
+ return res
97
+ }
98
+ const report = await runAcceptance({
99
+ id,
100
+ ...(topology ? { topology } : { ports }),
101
+ query: recording,
102
+ ...(timeoutMs ? { timeoutMs } : {}),
103
+ ...(waitMs ? { waitMs } : {}),
104
+ ...(intervalMs ? { intervalMs } : {}),
105
+ ...(onProgress ? { onProgress } : {}),
106
+ })
107
+ const checks = report.results.map((r) => ({
108
+ id: r.id,
109
+ on: r.device,
110
+ command: r.command,
111
+ pass: r.pass,
112
+ asserted: r.asserted,
113
+ why: r.why,
114
+ waitedMs: r.waitedMs || 0,
115
+ output: captured.get(`${r.device}|${r.command}`) ?? '',
116
+ }))
117
+ return { ...report, checks }
118
+ }
119
+
120
+ /**
121
+ * Gather everything the document needs from the running lab.
122
+ *
123
+ * @param {{
124
+ * id: string,
125
+ * topology?: string,
126
+ * query?: Function,
127
+ * timeoutMs?: number,
128
+ * waitMs?: number,
129
+ * intervalMs?: number,
130
+ * onProgress?: Function,
131
+ * }} options
132
+ */
133
+ export async function collectEvidence(options = {}) {
134
+ const { id, topology, query = queryDevice, timeoutMs, waitMs, intervalMs, onProgress } = options
135
+ const scenario = SCENARIOS[id]
136
+ if (!scenario) {
137
+ throw new Error(`unknown scenario "${id}"; available: ${Object.keys(SCENARIOS).join(', ')}`)
138
+ }
139
+
140
+ let devices = []
141
+ let links = []
142
+ let parsedPath = null
143
+ if (topology) {
144
+ const parsed = await readTopology(topology)
145
+ parsedPath = topology
146
+ devices = parsed.devices.map((d) => ({ name: d.name, model: d.model, comPort: d.comPort }))
147
+ links = parsed.links.map((l) => ({
148
+ a: l.srcName,
149
+ aPort: l.srcPort,
150
+ b: l.dstName,
151
+ bPort: l.dstPort,
152
+ media: l.media,
153
+ }))
154
+ } else if (hasLab(id)) {
155
+ // No .topo on disk: describe the blueprint so the topology chapter is still
156
+ // accurate, and use the ports the blueprint would hand out.
157
+ const blueprint = buildScenarioTopology(id)
158
+ devices = blueprint.devices.map((d) => ({ name: d.name, model: d.model, comPort: d.comPort }))
159
+ links = blueprint.links.map((l) => ({
160
+ a: l.srcName,
161
+ aPort: l.srcPort,
162
+ b: l.dstName,
163
+ bPort: l.dstPort,
164
+ media: l.media,
165
+ }))
166
+ }
167
+
168
+ const portMap = new Map()
169
+ for (const device of devices) {
170
+ if (device.comPort) portMap.set(device.name, device.comPort)
171
+ }
172
+
173
+ const addressing = hasLab(id) ? labAddressing(id) : []
174
+ const configs = await collectConfigs(devices, query, timeoutMs)
175
+ const checks = await collectChecks(id, {
176
+ topology: parsedPath,
177
+ ports: parsedPath ? undefined : portMap,
178
+ query,
179
+ timeoutMs,
180
+ waitMs,
181
+ intervalMs,
182
+ onProgress,
183
+ })
184
+
185
+ return {
186
+ id,
187
+ title: scenario.title,
188
+ titleEn: scenario.titleEn,
189
+ level: scenario.level,
190
+ source: scenario.source,
191
+ goal: scenario.goal,
192
+ steps: scenario.steps || [],
193
+ pitfalls: scenario.pitfalls || [],
194
+ devices,
195
+ links,
196
+ addressing,
197
+ configs,
198
+ verdict: { ok: checks.ok, passed: checks.passed, failed: checks.failed, unverified: checks.unverified, total: checks.total },
199
+ checks: checks.checks,
200
+ sourceKind: checks.source,
201
+ topologyPath: parsedPath,
202
+ generatedAt: today(),
203
+ }
204
+ }
205
+
206
+ function verdictSentence(evidence) {
207
+ const { ok, passed, failed, unverified, total } = evidence.verdict
208
+ if (!total) return '本次运行没有可执行的验证项。'
209
+ if (ok === true) return `验证通过:${passed}/${total} 项检查全部满足,配置状态与预期一致。`
210
+ if (ok === false) return `验证未通过:${failed}/${total} 项检查失败,详见第四章的失败原因。`
211
+ return (
212
+ `本次运行未验证:${unverified}/${total} 项检查没有断言,${passed} 项命令被设备接受。` +
213
+ '命令被接受不等于状态正确,这一章不能作为达标证明。'
214
+ )
215
+ }
216
+
217
+ function verdictNote(evidence) {
218
+ const { ok } = evidence.verdict
219
+ if (ok === true) return '验证结论:通过。'
220
+ if (ok === false) return '验证结论:未通过。'
221
+ return '验证结论:未验证。这些检查没有断言,只能证明命令被设备接受。'
222
+ }
223
+
224
+ /** Build the document blocks for a collected evidence set. */
225
+ export function buildReportBlocks(evidence) {
226
+ const blocks = []
227
+ const push = (...items) => blocks.push(...items)
228
+
229
+ push(
230
+ { type: 'title', text: `${evidence.title} 实验报告` },
231
+ { type: 'subtitle', text: `${evidence.id} · dsh-ensp · ${evidence.generatedAt}` },
232
+ )
233
+
234
+ const deviceCount = evidence.devices.length
235
+ push(
236
+ { type: 'caption', text: '文档信息' },
237
+ {
238
+ type: 'table',
239
+ header: ['项目', '内容'],
240
+ rows: [
241
+ ['实验场景', `${evidence.title}(${evidence.id})`],
242
+ ['实验级别', evidence.level],
243
+ ['设备数量', String(deviceCount)],
244
+ ['拓扑文件', evidence.topologyPath ? basename(evidence.topologyPath) : '未指定,按蓝图记录'],
245
+ ['验证来源', evidence.sourceKind === 'curated' ? '针对性断言' : '仅命令执行'],
246
+ ['验证结论', verdictSentence(evidence)],
247
+ ['生成时间', evidence.generatedAt],
248
+ ],
249
+ widths: [2000, 7000],
250
+ },
251
+ { type: 'note', text: verdictSentence(evidence) },
252
+ )
253
+
254
+ // ---- 一、实验目的
255
+ push({ type: 'heading', level: 1, text: '一、实验目的' })
256
+ push({ type: 'para', text: `本次实验的目标是${evidence.goal}。` })
257
+ if (evidence.steps.length) {
258
+ push({ type: 'para', text: '需要完成的工作:' })
259
+ push({ type: 'bullets', items: evidence.steps.map((s) => s.do) })
260
+ }
261
+
262
+ // ---- 二、拓扑与地址规划
263
+ push({ type: 'heading', level: 1, text: '二、拓扑与地址规划' })
264
+ if (deviceCount) {
265
+ push({ type: 'para', text: `实验拓扑包含 ${deviceCount} 台设备、${evidence.links.length} 条链路。` })
266
+ push(
267
+ { type: 'caption', text: '表 2-1 设备清单' },
268
+ {
269
+ type: 'table',
270
+ header: ['设备', '型号', '控制台端口'],
271
+ rows: evidence.devices.map((d) => [d.name, d.model, d.comPort ? String(d.comPort) : '无']),
272
+ widths: [3000, 3000, 3000],
273
+ },
274
+ )
275
+ } else {
276
+ push({ type: 'para', text: '本次运行未指定拓扑文件,拓扑部分按蓝图记录,实际连接以运行的实验为准。' })
277
+ }
278
+
279
+ if (evidence.links.length) {
280
+ push(
281
+ { type: 'caption', text: '表 2-2 链路连接' },
282
+ {
283
+ type: 'table',
284
+ header: ['本端设备', '本端接口', '对端设备', '对端接口', '介质'],
285
+ rows: evidence.links.map((l) => [l.a, l.aPort, l.b, l.bPort, l.media]),
286
+ widths: [1800, 2400, 1800, 2400, 1400],
287
+ },
288
+ )
289
+ }
290
+
291
+ if (evidence.addressing.length) {
292
+ push({ type: 'para', text: '地址规划如下表。接口名称与设备上的实际名称一致。' })
293
+ push(
294
+ { type: 'caption', text: '表 2-3 地址规划' },
295
+ {
296
+ type: 'table',
297
+ header: ['设备', '接口', '地址', '说明'],
298
+ rows: evidence.addressing.map((row) => [row.device, row.port, row.ip, row.note || '']),
299
+ widths: [1600, 3000, 2600, 1800],
300
+ },
301
+ )
302
+ } else {
303
+ push({ type: 'note', text: '本场景的蓝图没有地址规划表,地址配置见第三章各设备的配置原文。' })
304
+ }
305
+
306
+ // ---- 三、配置思路与命令
307
+ push({ type: 'heading', level: 1, text: '三、配置思路与命令' })
308
+ if (evidence.steps.length) {
309
+ push({ type: 'para', text: '配置顺序按下面的步骤推进,每一步的验证命令列在括号里。' })
310
+ push({
311
+ type: 'steps',
312
+ items: evidence.steps.map((s) => (s.verify ? `${s.do}(验证:${s.verify})` : s.do)),
313
+ })
314
+ }
315
+ push({
316
+ type: 'para',
317
+ text: '下面的配置原文取自各设备执行 `display current-configuration` 的结果,是本次运行实际生效的内容。',
318
+ })
319
+
320
+ for (const config of evidence.configs) {
321
+ push({ type: 'heading', level: 2, text: config.device })
322
+ if (config.note && !config.text) {
323
+ push({ type: 'note', text: config.note })
324
+ continue
325
+ }
326
+ push({ type: 'code', text: config.text })
327
+ if (config.note) push({ type: 'note', text: config.note })
328
+ }
329
+
330
+ // ---- 四、验证结果
331
+ push({ type: 'heading', level: 1, text: '四、验证结果' })
332
+ push({ type: 'para', text: verdictSentence(evidence) })
333
+
334
+ let index = 0
335
+ for (const check of evidence.checks) {
336
+ index += 1
337
+ const mark = check.pass === true ? '通过' : check.pass === null ? '未验证' : '未通过'
338
+ push({ type: 'heading', level: 2, text: `4.${index} ${check.id}(${check.on})` })
339
+ push({ type: 'para', text: `检查命令:\`${check.command}\`。判定结果:${mark}。` })
340
+ if (check.output) {
341
+ push({ type: 'code', text: check.output })
342
+ } else {
343
+ push({ type: 'note', text: '该命令没有返回内容。' })
344
+ }
345
+ if (check.pass !== true) {
346
+ const waited = check.waitedMs ? `(已等待 ${Math.round(check.waitedMs / 1000)} 秒)` : ''
347
+ push({ type: 'note', text: `判定说明:${check.why}${waited}` })
348
+ }
349
+ }
350
+
351
+ push({ type: 'note', text: verdictNote(evidence) })
352
+
353
+ // ---- 五、问题与结论
354
+ push({ type: 'heading', level: 1, text: '五、问题与结论' })
355
+ if (evidence.pitfalls.length) {
356
+ push({ type: 'para', text: '这个实验容易出错的地方:' })
357
+ push({ type: 'bullets', items: evidence.pitfalls })
358
+ }
359
+ push({ type: 'heading', level: 2, text: '结论' })
360
+ push({ type: 'para', text: verdictSentence(evidence) })
361
+
362
+ const failed = evidence.checks.filter((c) => c.pass === false)
363
+ if (failed.length) {
364
+ push({ type: 'para', text: '需要处理的问题:' })
365
+ push({ type: 'bullets', items: failed.map((c) => `${c.id}(${c.on}):${c.why}`) })
366
+ }
367
+
368
+ // ---- 六、附录
369
+ push({ type: 'heading', level: 1, text: '六、附录' })
370
+ push({ type: 'heading', level: 2, text: '复现步骤' })
371
+ push({
372
+ type: 'steps',
373
+ items: [
374
+ `生成拓扑:\`ensp preset ${evidence.id} --topo ${evidence.id}.topo\``,
375
+ `启动设备:\`ensp up --topology ${evidence.id}.topo\``,
376
+ '按第三章的配置原文在各设备上执行配置',
377
+ `运行验证:\`ensp verify ${evidence.id} --topology ${evidence.id}.topo\``,
378
+ `生成文档:\`ensp report ${evidence.id} --topology ${evidence.id}.topo\``,
379
+ ],
380
+ })
381
+ push({ type: 'heading', level: 2, text: '验证命令清单' })
382
+ push({ type: 'caption', text: '表 6-1 验证命令与结果' })
383
+ push({
384
+ type: 'table',
385
+ header: ['序号', '设备', '命令', '结果'],
386
+ rows: evidence.checks.slice(0, APPENDIX_ROWS).map((c, i) => [
387
+ String(i + 1),
388
+ String(c.on),
389
+ c.command,
390
+ c.pass === true ? '通过' : c.pass === null ? '未验证' : '未通过',
391
+ ]),
392
+ widths: [900, 1600, 5100, 1400],
393
+ })
394
+
395
+ return blocks
396
+ }
397
+
398
+ /**
399
+ * Collect evidence, render the document and write it to disk.
400
+ *
401
+ * @returns {Promise<{path: string, bytes: number, evidence: object}>}
402
+ */
403
+ export async function writeReport(options = {}) {
404
+ const { out, ...rest } = options
405
+ const evidence = await collectEvidence(rest)
406
+ const blocks = buildReportBlocks(evidence)
407
+ const buffer = buildDocx(blocks)
408
+ const path = out || `${evidence.id}-report.docx`
409
+ writeFileSync(path, buffer)
410
+ return { path, bytes: buffer.length, evidence }
411
+ }