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
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes .docx files without any runtime dependency.
|
|
3
|
+
*
|
|
4
|
+
* A .docx is a zip holding OOXML parts. This module builds the parts a plain
|
|
5
|
+
* report needs (content types, package relationships, document, styles,
|
|
6
|
+
* numbering) and zips them itself, so the package installs nothing and works
|
|
7
|
+
* offline.
|
|
8
|
+
*
|
|
9
|
+
* Blocks are plain objects; see {@link renderBlocks} for the shapes. Text may
|
|
10
|
+
* carry two inline markers, `**bold**` and `` `code` ``, which become runs.
|
|
11
|
+
*
|
|
12
|
+
* @module core/docx
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { deflateRawSync } from 'node:zlib'
|
|
16
|
+
|
|
17
|
+
const CRC_TABLE = (() => {
|
|
18
|
+
const table = new Int32Array(256)
|
|
19
|
+
for (let i = 0; i < 256; i += 1) {
|
|
20
|
+
let c = i
|
|
21
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
|
22
|
+
table[i] = c
|
|
23
|
+
}
|
|
24
|
+
return table
|
|
25
|
+
})()
|
|
26
|
+
|
|
27
|
+
/** CRC32, needed by both the local and the central zip records. */
|
|
28
|
+
function crc32(buffer) {
|
|
29
|
+
let c = -1
|
|
30
|
+
for (let i = 0; i < buffer.length; i += 1) c = (c >>> 8) ^ CRC_TABLE[(c ^ buffer[i]) & 0xff]
|
|
31
|
+
return (c ^ -1) >>> 0
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build a zip from `{name, data}` entries. Every entry is deflated when that
|
|
36
|
+
* actually saves space and stored otherwise, which keeps tiny parts readable in
|
|
37
|
+
* a hex dump while still compressing the document body.
|
|
38
|
+
*/
|
|
39
|
+
export function zip(entries) {
|
|
40
|
+
const chunks = []
|
|
41
|
+
const central = []
|
|
42
|
+
let offset = 0
|
|
43
|
+
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
const name = Buffer.from(entry.name, 'utf8')
|
|
46
|
+
const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data, 'utf8')
|
|
47
|
+
const deflated = deflateRawSync(data, { level: 9 })
|
|
48
|
+
const compressed = deflated.length < data.length
|
|
49
|
+
const body = compressed ? deflated : data
|
|
50
|
+
const method = compressed ? 8 : 0
|
|
51
|
+
const crc = crc32(data)
|
|
52
|
+
|
|
53
|
+
const local = Buffer.alloc(30)
|
|
54
|
+
local.writeUInt32LE(0x04034b50, 0)
|
|
55
|
+
local.writeUInt16LE(20, 4)
|
|
56
|
+
local.writeUInt16LE(0, 6)
|
|
57
|
+
local.writeUInt16LE(method, 8)
|
|
58
|
+
local.writeUInt16LE(0, 10)
|
|
59
|
+
local.writeUInt16LE(0x0021, 12)
|
|
60
|
+
local.writeUInt32LE(crc, 14)
|
|
61
|
+
local.writeUInt32LE(body.length, 18)
|
|
62
|
+
local.writeUInt32LE(data.length, 22)
|
|
63
|
+
local.writeUInt16LE(name.length, 26)
|
|
64
|
+
local.writeUInt16LE(0, 28)
|
|
65
|
+
|
|
66
|
+
chunks.push(local, name, body)
|
|
67
|
+
central.push({ name, crc, body: body.length, size: data.length, method, offset })
|
|
68
|
+
offset += local.length + name.length + body.length
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const centralStart = offset
|
|
72
|
+
for (const entry of central) {
|
|
73
|
+
const head = Buffer.alloc(46)
|
|
74
|
+
head.writeUInt32LE(0x02014b50, 0)
|
|
75
|
+
head.writeUInt16LE(20, 4)
|
|
76
|
+
head.writeUInt16LE(20, 6)
|
|
77
|
+
head.writeUInt16LE(0, 8)
|
|
78
|
+
head.writeUInt16LE(entry.method, 10)
|
|
79
|
+
head.writeUInt16LE(0, 12)
|
|
80
|
+
head.writeUInt16LE(0x0021, 14)
|
|
81
|
+
head.writeUInt32LE(entry.crc, 16)
|
|
82
|
+
head.writeUInt32LE(entry.body, 20)
|
|
83
|
+
head.writeUInt32LE(entry.size, 24)
|
|
84
|
+
head.writeUInt16LE(entry.name.length, 28)
|
|
85
|
+
head.writeUInt16LE(0, 30)
|
|
86
|
+
head.writeUInt16LE(0, 32)
|
|
87
|
+
head.writeUInt16LE(0, 34)
|
|
88
|
+
head.writeUInt16LE(0, 36)
|
|
89
|
+
head.writeUInt32LE(0, 38)
|
|
90
|
+
head.writeUInt32LE(entry.offset, 42)
|
|
91
|
+
chunks.push(head, entry.name)
|
|
92
|
+
offset += head.length + entry.name.length
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const end = Buffer.alloc(22)
|
|
96
|
+
end.writeUInt32LE(0x06054b50, 0)
|
|
97
|
+
end.writeUInt16LE(0, 4)
|
|
98
|
+
end.writeUInt16LE(0, 6)
|
|
99
|
+
end.writeUInt16LE(central.length, 8)
|
|
100
|
+
end.writeUInt16LE(central.length, 10)
|
|
101
|
+
end.writeUInt32LE(offset - centralStart, 12)
|
|
102
|
+
end.writeUInt32LE(centralStart, 16)
|
|
103
|
+
end.writeUInt16LE(0, 20)
|
|
104
|
+
chunks.push(end)
|
|
105
|
+
|
|
106
|
+
return Buffer.concat(chunks)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** XML-escape text and drop control characters, which OOXML rejects. */
|
|
110
|
+
export function escapeXml(value) {
|
|
111
|
+
return String(value ?? '')
|
|
112
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '')
|
|
113
|
+
.replace(/&/g, '&')
|
|
114
|
+
.replace(/</g, '<')
|
|
115
|
+
.replace(/>/g, '>')
|
|
116
|
+
.replace(/"/g, '"')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const RUN_FONTS = '<w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:eastAsia="\u5b8b\u4f53"/>'
|
|
120
|
+
const CODE_FONTS = '<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas" w:eastAsia="Consolas"/>'
|
|
121
|
+
|
|
122
|
+
/** Turn `**bold**` and backtick spans into runs. */
|
|
123
|
+
export function inlineRuns(text, { size = 24, bold = false, color = null } = {}) {
|
|
124
|
+
const parts = String(text ?? '').split(/(\*\*[^*]+\*\*|`[^`]+`)/)
|
|
125
|
+
const runs = []
|
|
126
|
+
for (const part of parts) {
|
|
127
|
+
if (!part) continue
|
|
128
|
+
let body = part
|
|
129
|
+
let isBold = bold
|
|
130
|
+
let isCode = false
|
|
131
|
+
if (part.startsWith('**') && part.endsWith('**') && part.length > 4) {
|
|
132
|
+
body = part.slice(2, -2)
|
|
133
|
+
isBold = true
|
|
134
|
+
} else if (part.startsWith('`') && part.endsWith('`') && part.length > 2) {
|
|
135
|
+
body = part.slice(1, -1)
|
|
136
|
+
isCode = true
|
|
137
|
+
}
|
|
138
|
+
const props = [
|
|
139
|
+
isCode ? CODE_FONTS : RUN_FONTS,
|
|
140
|
+
`<w:sz w:val="${isCode ? Math.max(16, size - 2) : size}"/>`,
|
|
141
|
+
`<w:szCs w:val="${isCode ? Math.max(16, size - 2) : size}"/>`,
|
|
142
|
+
isBold ? '<w:b/>' : '',
|
|
143
|
+
color ? `<w:color w:val="${color}"/>` : '',
|
|
144
|
+
].join('')
|
|
145
|
+
runs.push(`<w:r><w:rPr>${props}</w:rPr><w:t xml:space="preserve">${escapeXml(body)}</w:t></w:r>`)
|
|
146
|
+
}
|
|
147
|
+
if (!runs.length) runs.push(`<w:r><w:rPr>${RUN_FONTS}</w:rPr><w:t></w:t></w:r>`)
|
|
148
|
+
return runs.join('')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function paragraph(text, { style = null, align = null, size = 24, bold = false, spaceAfter = 80, indent = null } = {}) {
|
|
152
|
+
const props = [
|
|
153
|
+
style ? `<w:pStyle w:val="${style}"/>` : '',
|
|
154
|
+
align ? `<w:jc w:val="${align}"/>` : '',
|
|
155
|
+
indent ? `<w:ind w:firstLine="${indent}"/>` : '',
|
|
156
|
+
`<w:spacing w:after="${spaceAfter}" w:line="320" w:lineRule="auto"/>`,
|
|
157
|
+
].join('')
|
|
158
|
+
return `<w:p><w:pPr>${props}</w:pPr>${inlineRuns(text, { size, bold })}</w:p>`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function numberedParagraph(text, numId) {
|
|
162
|
+
return (
|
|
163
|
+
'<w:p><w:pPr>' +
|
|
164
|
+
`<w:numPr><w:ilvl w:val="0"/><w:numId w:val="${numId}"/></w:numPr>` +
|
|
165
|
+
'<w:spacing w:after="60" w:line="320" w:lineRule="auto"/>' +
|
|
166
|
+
`</w:pPr>${inlineRuns(text, { size: 24 })}</w:p>`
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function codeBlock(text) {
|
|
171
|
+
const lines = String(text ?? '').replace(/\r\n?/g, '\n').replace(/\s+$/, '').split('\n')
|
|
172
|
+
const runs = []
|
|
173
|
+
lines.forEach((line, index) => {
|
|
174
|
+
if (index) runs.push('<w:r><w:br/></w:r>')
|
|
175
|
+
runs.push(
|
|
176
|
+
`<w:r><w:rPr>${CODE_FONTS}<w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr>` +
|
|
177
|
+
`<w:t xml:space="preserve">${escapeXml(line) || ' '}</w:t></w:r>`,
|
|
178
|
+
)
|
|
179
|
+
})
|
|
180
|
+
return (
|
|
181
|
+
'<w:p><w:pPr><w:pStyle w:val="Code"/>' +
|
|
182
|
+
'<w:shd w:val="clear" w:color="auto" w:fill="F4F4F4"/>' +
|
|
183
|
+
'<w:spacing w:after="120" w:line="240" w:lineRule="auto"/></w:pPr>' +
|
|
184
|
+
`${runs.join('')}</w:p>`
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function tableBlock({ header = [], rows = [], widths = [] }) {
|
|
189
|
+
const columns = Math.max(header.length, ...rows.map((r) => r.length), 1)
|
|
190
|
+
const total = 9000
|
|
191
|
+
const cells = widths.length === columns ? widths : new Array(columns).fill(Math.floor(total / columns))
|
|
192
|
+
const grid = cells.map((w) => `<w:gridCol w:w="${w}"/>`).join('')
|
|
193
|
+
const border =
|
|
194
|
+
'<w:tblBorders>' +
|
|
195
|
+
'<w:top w:val="single" w:sz="4" w:space="0" w:color="808080"/>' +
|
|
196
|
+
'<w:left w:val="single" w:sz="4" w:space="0" w:color="808080"/>' +
|
|
197
|
+
'<w:bottom w:val="single" w:sz="4" w:space="0" w:color="808080"/>' +
|
|
198
|
+
'<w:right w:val="single" w:sz="4" w:space="0" w:color="808080"/>' +
|
|
199
|
+
'<w:insideH w:val="single" w:sz="4" w:space="0" w:color="808080"/>' +
|
|
200
|
+
'<w:insideV w:val="single" w:sz="4" w:space="0" w:color="808080"/>' +
|
|
201
|
+
'</w:tblBorders>'
|
|
202
|
+
const tblPr =
|
|
203
|
+
'<w:tblPr><w:tblW w:w="5000" w:type="pct"/>' + border +
|
|
204
|
+
'<w:tblLayout w:type="fixed"/></w:tblPr>'
|
|
205
|
+
|
|
206
|
+
const buildRow = (values, isHeader) => {
|
|
207
|
+
const tcs = []
|
|
208
|
+
for (let i = 0; i < columns; i += 1) {
|
|
209
|
+
const value = values[i] ?? ''
|
|
210
|
+
const shading = isHeader ? '<w:shd w:val="clear" w:color="auto" w:fill="EDEDED"/>' : ''
|
|
211
|
+
tcs.push(
|
|
212
|
+
'<w:tc><w:tcPr>' +
|
|
213
|
+
`<w:tcW w:w="${cells[i]}" w:type="dxa"/>` +
|
|
214
|
+
`${shading}<w:vAlign w:val="center"/></w:tcPr>` +
|
|
215
|
+
`<w:p><w:pPr><w:spacing w:after="20" w:line="260" w:lineRule="auto"/>` +
|
|
216
|
+
`${isHeader ? '<w:jc w:val="center"/>' : ''}</w:pPr>` +
|
|
217
|
+
`${inlineRuns(value, { size: 21, bold: isHeader })}</w:p></w:tc>`,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
return `<w:tr>${isHeader ? '<w:trPr><w:tblHeader/></w:trPr>' : ''}${tcs.join('')}</w:tr>`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const headerRow = header.length ? buildRow(header, true) : ''
|
|
224
|
+
const bodyRows = rows.map((row) => buildRow(row, false)).join('')
|
|
225
|
+
return `<w:tbl>${tblPr}<w:tblGrid>${grid}</w:tblGrid>${headerRow}${bodyRows}</w:tbl>` +
|
|
226
|
+
'<w:p><w:pPr><w:spacing w:after="0"/></w:pPr></w:p>'
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Render one block. Unknown types are ignored rather than crashing the build. */
|
|
230
|
+
function renderBlock(block) {
|
|
231
|
+
switch (block.type) {
|
|
232
|
+
case 'title':
|
|
233
|
+
return paragraph(block.text, { style: 'Title', align: 'center', size: 44, bold: true, spaceAfter: 160 })
|
|
234
|
+
case 'subtitle':
|
|
235
|
+
return paragraph(block.text, { style: 'Subtitle', align: 'center', size: 28, spaceAfter: 240 })
|
|
236
|
+
case 'heading':
|
|
237
|
+
return paragraph(block.text, { style: `Heading${Math.min(4, Math.max(1, block.level ?? 1))}`, spaceAfter: 120 })
|
|
238
|
+
case 'para':
|
|
239
|
+
return paragraph(block.text, { indent: block.indent === false ? null : 480 })
|
|
240
|
+
case 'bullets':
|
|
241
|
+
return (block.items || []).map((item) => numberedParagraph(item, 1)).join('')
|
|
242
|
+
case 'steps':
|
|
243
|
+
return (block.items || []).map((item) => numberedParagraph(item, 2)).join('')
|
|
244
|
+
case 'code':
|
|
245
|
+
return codeBlock(block.text)
|
|
246
|
+
case 'table':
|
|
247
|
+
return tableBlock(block)
|
|
248
|
+
case 'caption':
|
|
249
|
+
return paragraph(block.text, { style: 'Caption', align: 'center', size: 20, spaceAfter: 60 })
|
|
250
|
+
case 'note':
|
|
251
|
+
return paragraph(block.text, { style: 'Note', indent: false })
|
|
252
|
+
case 'pageBreak':
|
|
253
|
+
return '<w:p><w:r><w:br w:type="page"/></w:r></w:p>'
|
|
254
|
+
default:
|
|
255
|
+
return ''
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Render a list of blocks into the body of `word/document.xml`. */
|
|
260
|
+
export function renderBlocks(blocks) {
|
|
261
|
+
return (blocks || []).map(renderBlock).join('')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
265
|
+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
266
|
+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
267
|
+
<Default Extension="xml" ContentType="application/xml"/>
|
|
268
|
+
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
|
269
|
+
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
|
270
|
+
<Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>
|
|
271
|
+
</Types>`
|
|
272
|
+
|
|
273
|
+
const ROOT_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
274
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
275
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
|
276
|
+
</Relationships>`
|
|
277
|
+
|
|
278
|
+
const DOCUMENT_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
279
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
280
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
|
281
|
+
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>
|
|
282
|
+
</Relationships>`
|
|
283
|
+
|
|
284
|
+
const NUMBERING = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
285
|
+
<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
286
|
+
<w:abstractNum w:abstractNumId="0">
|
|
287
|
+
<w:multiLevelType w:val="singleLevel"/>
|
|
288
|
+
<w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val="\u2022"/><w:lvlJc w:val="left"/><w:pPr><w:ind w:left="840" w:hanging="360"/></w:pPr><w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/></w:rPr></w:lvl>
|
|
289
|
+
</w:abstractNum>
|
|
290
|
+
<w:abstractNum w:abstractNumId="1">
|
|
291
|
+
<w:multiLevelType w:val="singleLevel"/>
|
|
292
|
+
<w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/><w:lvlJc w:val="left"/><w:pPr><w:ind w:left="840" w:hanging="420"/></w:pPr></w:lvl>
|
|
293
|
+
</w:abstractNum>
|
|
294
|
+
<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>
|
|
295
|
+
<w:num w:numId="2"><w:abstractNumId w:val="1"/></w:num>
|
|
296
|
+
</w:numbering>`
|
|
297
|
+
|
|
298
|
+
function headingStyle(id, name, size, outline, before, after) {
|
|
299
|
+
return (
|
|
300
|
+
`<w:style w:type="paragraph" w:styleId="${id}"><w:name w:val="${name}"/>` +
|
|
301
|
+
'<w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>' +
|
|
302
|
+
`<w:pPr><w:keepNext/><w:outlineLvl w:val="${outline}"/>` +
|
|
303
|
+
`<w:spacing w:before="${before}" w:after="${after}"/></w:pPr>` +
|
|
304
|
+
`<w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:eastAsia="\u9ed1\u4f53"/><w:b/>` +
|
|
305
|
+
`<w:sz w:val="${size}"/><w:szCs w:val="${size}"/></w:rPr></w:style>`
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const STYLES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
310
|
+
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
311
|
+
<w:docDefaults><w:rPrDefault><w:rPr>${RUN_FONTS}<w:sz w:val="24"/><w:szCs w:val="24"/></w:rPr></w:rPrDefault>
|
|
312
|
+
<w:pPrDefault><w:pPr><w:spacing w:after="80" w:line="320" w:lineRule="auto"/></w:pPr></w:pPrDefault></w:docDefaults>
|
|
313
|
+
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>
|
|
314
|
+
<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:qFormat/>
|
|
315
|
+
<w:pPr><w:jc w:val="center"/><w:spacing w:before="240" w:after="160"/></w:pPr>
|
|
316
|
+
<w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:eastAsia="\u9ed1\u4f53"/><w:b/><w:sz w:val="44"/></w:rPr></w:style>
|
|
317
|
+
<w:style w:type="paragraph" w:styleId="Subtitle"><w:name w:val="Subtitle"/><w:basedOn w:val="Normal"/><w:qFormat/>
|
|
318
|
+
<w:pPr><w:jc w:val="center"/><w:spacing w:after="240"/></w:pPr>
|
|
319
|
+
<w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:eastAsia="\u9ed1\u4f53"/><w:sz w:val="28"/><w:color w:val="595959"/></w:rPr></w:style>
|
|
320
|
+
${headingStyle('Heading1', 'heading 1', 32, 0, 320, 160)}
|
|
321
|
+
${headingStyle('Heading2', 'heading 2', 28, 1, 240, 120)}
|
|
322
|
+
${headingStyle('Heading3', 'heading 3', 24, 2, 200, 100)}
|
|
323
|
+
${headingStyle('Heading4', 'heading 4', 24, 3, 160, 80)}
|
|
324
|
+
<w:style w:type="paragraph" w:styleId="Code"><w:name w:val="Code"/><w:basedOn w:val="Normal"/>
|
|
325
|
+
<w:pPr><w:ind w:left="240" w:right="120"/><w:spacing w:after="120" w:line="240" w:lineRule="auto"/></w:pPr>
|
|
326
|
+
<w:rPr>${CODE_FONTS}<w:sz w:val="18"/></w:rPr></w:style>
|
|
327
|
+
<w:style w:type="paragraph" w:styleId="Caption"><w:name w:val="caption"/><w:basedOn w:val="Normal"/>
|
|
328
|
+
<w:pPr><w:jc w:val="center"/><w:keepNext/><w:spacing w:before="120" w:after="60"/></w:pPr>
|
|
329
|
+
<w:rPr><w:sz w:val="20"/><w:color w:val="404040"/></w:rPr></w:style>
|
|
330
|
+
<w:style w:type="paragraph" w:styleId="Note"><w:name w:val="Note"/><w:basedOn w:val="Normal"/>
|
|
331
|
+
<w:pPr><w:shd w:val="clear" w:color="auto" w:fill="FBF6E9"/><w:ind w:left="240" w:right="240"/><w:spacing w:before="80" w:after="120"/></w:pPr>
|
|
332
|
+
<w:rPr><w:sz w:val="21"/><w:color w:val="7F5F00"/></w:rPr></w:style>
|
|
333
|
+
</w:styles>`
|
|
334
|
+
|
|
335
|
+
const SECTION =
|
|
336
|
+
'<w:sectPr><w:pgSz w:w="11906" w:h="16838"/>' +
|
|
337
|
+
'<w:pgMar w:top="1440" w:right="1418" w:bottom="1440" w:left="1418" w:header="851" w:footer="992" w:gutter="0"/>' +
|
|
338
|
+
'<w:docGrid w:linePitch="312"/></w:sectPr>'
|
|
339
|
+
|
|
340
|
+
/** Wrap rendered blocks into a complete `word/document.xml`. */
|
|
341
|
+
export function documentXml(blocks) {
|
|
342
|
+
return (
|
|
343
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
344
|
+
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
|
|
345
|
+
`<w:body>${renderBlocks(blocks)}${SECTION}</w:body></w:document>`
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Build the whole .docx as a Buffer. */
|
|
350
|
+
export function buildDocx(blocks) {
|
|
351
|
+
return zip([
|
|
352
|
+
{ name: '[Content_Types].xml', data: CONTENT_TYPES },
|
|
353
|
+
{ name: '_rels/.rels', data: ROOT_RELS },
|
|
354
|
+
{ name: 'word/document.xml', data: documentXml(blocks) },
|
|
355
|
+
{ name: 'word/_rels/document.xml.rels', data: DOCUMENT_RELS },
|
|
356
|
+
{ name: 'word/styles.xml', data: STYLES },
|
|
357
|
+
{ name: 'word/numbering.xml', data: NUMBERING },
|
|
358
|
+
])
|
|
359
|
+
}
|
package/src/core/index.mjs
CHANGED
|
@@ -17,7 +17,6 @@ export {
|
|
|
17
17
|
portCount,
|
|
18
18
|
describePorts,
|
|
19
19
|
portsFromDeclarations,
|
|
20
|
-
declarationsFor,
|
|
21
20
|
} from './models.mjs'
|
|
22
21
|
|
|
23
22
|
// Kept for consumers written against the old shape; the table behind it was
|
|
@@ -52,6 +51,15 @@ export {
|
|
|
52
51
|
labModels,
|
|
53
52
|
} from './labs.mjs'
|
|
54
53
|
|
|
54
|
+
export {
|
|
55
|
+
labConfigs,
|
|
56
|
+
labConfigScript,
|
|
57
|
+
hasLabConfig,
|
|
58
|
+
countLabConfigs,
|
|
59
|
+
maskFromPrefix,
|
|
60
|
+
addressCommand,
|
|
61
|
+
} from './labconfig.mjs'
|
|
62
|
+
|
|
55
63
|
export {
|
|
56
64
|
probeDevice,
|
|
57
65
|
checkTopologySupport,
|
|
@@ -59,12 +67,12 @@ export {
|
|
|
59
67
|
imageNotesFor,
|
|
60
68
|
IMAGE_NOTES,
|
|
61
69
|
rootFromExe,
|
|
70
|
+
pluginMediaFiles,
|
|
62
71
|
} from './compat.mjs'
|
|
63
72
|
|
|
64
73
|
export {
|
|
65
74
|
doctor,
|
|
66
75
|
formatDoctor,
|
|
67
|
-
checkEnvironment,
|
|
68
76
|
probeInputInjection,
|
|
69
77
|
freeMemoryMb,
|
|
70
78
|
busyConsolePorts,
|
|
@@ -90,12 +98,15 @@ export {
|
|
|
90
98
|
locateWindow,
|
|
91
99
|
launchEnsp,
|
|
92
100
|
focusEnsp,
|
|
101
|
+
showEnsp,
|
|
93
102
|
minimiseEnsp,
|
|
94
103
|
clickToolbar,
|
|
95
104
|
checkOcclusion,
|
|
96
105
|
waitForConsole,
|
|
97
106
|
startAllDevices,
|
|
98
107
|
stopAllDevices,
|
|
108
|
+
topologyRendered,
|
|
109
|
+
canvasDeviceCount,
|
|
99
110
|
} from './launcher.mjs'
|
|
100
111
|
|
|
101
112
|
export {
|
|
@@ -123,13 +134,6 @@ export {
|
|
|
123
134
|
searchScenarios,
|
|
124
135
|
} from './presets.mjs'
|
|
125
136
|
|
|
126
|
-
export {
|
|
127
|
-
knowledgeTopics,
|
|
128
|
-
knowledgeFor,
|
|
129
|
-
searchKnowledge,
|
|
130
|
-
renderKnowledge,
|
|
131
|
-
} from './knowledge.mjs'
|
|
132
|
-
|
|
133
137
|
export {
|
|
134
138
|
curriculumLevels,
|
|
135
139
|
curriculumChapters,
|
|
@@ -146,3 +150,18 @@ export {
|
|
|
146
150
|
enspCandidates,
|
|
147
151
|
vboxCandidates,
|
|
148
152
|
} from './paths.mjs'
|
|
153
|
+
|
|
154
|
+
export {
|
|
155
|
+
buildDocx,
|
|
156
|
+
documentXml,
|
|
157
|
+
renderBlocks,
|
|
158
|
+
inlineRuns,
|
|
159
|
+
escapeXml,
|
|
160
|
+
zip,
|
|
161
|
+
} from './docx.mjs'
|
|
162
|
+
|
|
163
|
+
export {
|
|
164
|
+
collectEvidence,
|
|
165
|
+
buildReportBlocks,
|
|
166
|
+
writeReport,
|
|
167
|
+
} from './report.mjs'
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine usable configuration derived from a lab blueprint.
|
|
3
|
+
*
|
|
4
|
+
* A blueprint already carries the addressing plan: which device, which port,
|
|
5
|
+
* which address. That table was written for a reader to copy from. This module
|
|
6
|
+
* turns the same table into a VRP script so `ensp` can apply it without a human
|
|
7
|
+
* retyping sixteen interface blocks.
|
|
8
|
+
*
|
|
9
|
+
* The scope is deliberately the addressing plan and nothing else. Routing
|
|
10
|
+
* protocols, ACLs, VRRP groups and the rest stay in the scenario steps: those
|
|
11
|
+
* are what the exercise is about, and inventing a canonical answer for them
|
|
12
|
+
* would turn the skeleton into a paste-ready dump, which is the one thing the
|
|
13
|
+
* presets are built to avoid.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-ensp/labconfig
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { LABS } from './labs.mjs'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Turn the blueprint addressing table into one VRP script per device.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} id scenario id
|
|
24
|
+
* @returns {{device: string, script: string, interfaces: number}[]}
|
|
25
|
+
*/
|
|
26
|
+
export function labConfigs(id) {
|
|
27
|
+
const spec = LABS[id]
|
|
28
|
+
if (!spec) return []
|
|
29
|
+
|
|
30
|
+
/** @type {Map<string, string[]>} */
|
|
31
|
+
const byDevice = new Map()
|
|
32
|
+
for (const row of spec.addressing ?? []) {
|
|
33
|
+
const commands = byDevice.get(row.device) ?? []
|
|
34
|
+
// Interface names go in verbatim: the blueprint stores them exactly as the
|
|
35
|
+
// CLI accepts them (`LoopBack0`, `Vlanif10`, `GigabitEthernet0/0/1`).
|
|
36
|
+
commands.push(`interface ${row.port}`, ` ${addressCommand(row.ip)}`, ' quit')
|
|
37
|
+
byDevice.set(row.device, commands)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return [...byDevice].map(([device, body]) => {
|
|
41
|
+
const script = ['system-view', ...body, 'return'].join('\n')
|
|
42
|
+
return { device, script, interfaces: body.length / 3 }
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One script per scenario, devices separated by a comment line.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} id
|
|
50
|
+
* @returns {string} empty when the blueprint has no addressing plan
|
|
51
|
+
*/
|
|
52
|
+
export function labConfigScript(id) {
|
|
53
|
+
return labConfigs(id)
|
|
54
|
+
.map((entry) => [`# ${entry.device}`, entry.script].join('\n'))
|
|
55
|
+
.join('\n\n')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether this scenario has any addressing to apply. */
|
|
59
|
+
export function hasLabConfig(id) {
|
|
60
|
+
return (LABS[id]?.addressing?.length ?? 0) > 0
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** How many blueprints carry an addressing plan. */
|
|
64
|
+
export function countLabConfigs() {
|
|
65
|
+
return Object.keys(LABS).filter((id) => hasLabConfig(id)).length
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Prefix length to a dotted mask.
|
|
70
|
+
*
|
|
71
|
+
* @param {number} bits
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
export function maskFromPrefix(bits) {
|
|
75
|
+
// `0xffffffff << 32` is `0xffffffff << 0` in JavaScript, because the shift
|
|
76
|
+
// count is taken modulo 32, so /0 has to be answered before the shift.
|
|
77
|
+
if (bits === 0) return '0.0.0.0'
|
|
78
|
+
const value = (0xffffffff << (32 - bits)) >>> 0
|
|
79
|
+
return [24, 16, 8, 0].map((shift) => (value >>> shift) & 0xff).join('.')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* One `ip address` (or `ipv6 address`) command for a CIDR row.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} cidr e.g. `10.0.12.1/24` or `2001:db8:12::1/64`
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
export function addressCommand(cidr) {
|
|
89
|
+
const at = String(cidr).lastIndexOf('/')
|
|
90
|
+
if (at < 0) throw new Error(`addressing row "${cidr}" has no prefix length`)
|
|
91
|
+
const address = cidr.slice(0, at)
|
|
92
|
+
const bits = Number(cidr.slice(at + 1))
|
|
93
|
+
if (!Number.isInteger(bits) || bits < 0 || bits > 128) {
|
|
94
|
+
throw new Error(`addressing row "${cidr}" has a prefix that is not a number`)
|
|
95
|
+
}
|
|
96
|
+
if (address.includes(':')) return `ipv6 address ${address}/${bits}`
|
|
97
|
+
return `ip address ${address} ${maskFromPrefix(bits)}`
|
|
98
|
+
}
|
package/src/core/labs.mjs
CHANGED
|
@@ -809,19 +809,19 @@ export const LABS = {
|
|
|
809
809
|
],
|
|
810
810
|
),
|
|
811
811
|
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
//
|
|
815
|
-
//
|
|
812
|
+
// PPP and HDLC need real serial ports: an AR2220 rejects `link-protocol ppp` on a
|
|
813
|
+
// GE port with an empty error, so that path cannot be configured at all. The
|
|
814
|
+
// `Router` model carries four Serial ports out of the box, which is what this
|
|
815
|
+
// scenario is about, so both links ride Serial0/0/0 and Serial0/0/1.
|
|
816
816
|
'ppp-hdlc': lab(
|
|
817
|
-
[['R1', '
|
|
818
|
-
[['R1', '
|
|
819
|
-
['R1', '
|
|
820
|
-
[
|
|
821
|
-
{ device: 'R1', port: '
|
|
822
|
-
{ device: 'R2', port: '
|
|
823
|
-
{ device: 'R1', port: '
|
|
824
|
-
{ device: 'R2', port: '
|
|
817
|
+
[['R1', 'Router'], ['R2', 'Router']],
|
|
818
|
+
[['R1', 'Serial0/0/0', 'R2', 'Serial0/0/0'],
|
|
819
|
+
['R1', 'Serial0/0/1', 'R2', 'Serial0/0/1']],
|
|
820
|
+
[
|
|
821
|
+
{ device: 'R1', port: 'Serial0/0/0', ip: '10.0.12.1/30', note: 'link-protocol ppp + CHAP' },
|
|
822
|
+
{ device: 'R2', port: 'Serial0/0/0', ip: '10.0.12.2/30' },
|
|
823
|
+
{ device: 'R1', port: 'Serial0/0/1', ip: '10.0.13.1/30', note: 'link-protocol hdlc 对照' },
|
|
824
|
+
{ device: 'R2', port: 'Serial0/0/1', ip: '10.0.13.2/30' },
|
|
825
825
|
],
|
|
826
826
|
),
|
|
827
827
|
|