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/src/dsh/index.mjs
CHANGED
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
* The helpers below mirror what the first-party plugins do, so the two front-ends
|
|
20
20
|
* stay readable side by side.
|
|
21
21
|
*
|
|
22
|
+
* The tool names, descriptions and parameter maps themselves live in
|
|
23
|
+
* `../tools.mjs`, shared with the MCP server, so the two tables cannot drift
|
|
24
|
+
* apart. What stays here is the DSH-specific half: the output schema plus render
|
|
25
|
+
* function, and the handler that produces the display-ready value.
|
|
26
|
+
*
|
|
22
27
|
* @module dsh-ensp/dsh
|
|
23
28
|
*/
|
|
24
29
|
|
|
@@ -34,23 +39,26 @@ import {
|
|
|
34
39
|
labAddressing,
|
|
35
40
|
labDevices,
|
|
36
41
|
} from '../core/labs.mjs'
|
|
37
|
-
import {
|
|
42
|
+
import { countLabConfigs, hasLabConfig, labConfigScript } from '../core/labconfig.mjs'
|
|
43
|
+
import { checkTopologySupport, pluginMediaPlan, pluginModels, installPluginMedia } from '../core/compat.mjs'
|
|
38
44
|
import {
|
|
39
45
|
enspPid,
|
|
40
46
|
locateWindow,
|
|
41
47
|
launchEnsp,
|
|
48
|
+
showEnsp,
|
|
42
49
|
startAllDevices,
|
|
43
50
|
stopAllDevices,
|
|
44
51
|
waitForConsole,
|
|
45
52
|
} from '../core/launcher.mjs'
|
|
46
53
|
import { calibrateAnchors, inspectAnchors } from '../core/calibrate.mjs'
|
|
47
54
|
import { pushConfig, pushConfigMany, queryDevice } from '../core/provision.mjs'
|
|
55
|
+
import { writeReport } from '../core/report.mjs'
|
|
48
56
|
import { SCENARIOS, SCENARIO_IDS, renderScenario, searchScenarios, byLevel } from '../core/presets.mjs'
|
|
49
57
|
import { doctor, modelsOf } from '../core/doctor.mjs'
|
|
50
58
|
import { runAcceptance, hasChecks } from '../core/acceptance.mjs'
|
|
51
|
-
import { knowledgeFor, searchKnowledge, knowledgeTopics, renderKnowledge } from '../core/knowledge.mjs'
|
|
52
59
|
import { curriculumFor, renderCurriculum, searchCurriculum } from '../core/curriculum.mjs'
|
|
53
60
|
import { VERSION } from '../core/version.mjs'
|
|
61
|
+
import { TOOL_SPECS } from '../tools.mjs'
|
|
54
62
|
|
|
55
63
|
/** Declared hard dependency: the tool registry. */
|
|
56
64
|
export const inject = ['tools']
|
|
@@ -148,9 +156,6 @@ function emptyFor(node) {
|
|
|
148
156
|
/** One plain text content block. */
|
|
149
157
|
const text = (value) => [{ type: 'text', text: String(value) }]
|
|
150
158
|
|
|
151
|
-
/** Render a JSON payload for display without depending on any UI helper. */
|
|
152
|
-
const showJson = (value) => text(JSON.stringify(value, null, 2))
|
|
153
|
-
|
|
154
159
|
/**
|
|
155
160
|
* Register one tool with the harness.
|
|
156
161
|
*
|
|
@@ -168,6 +173,604 @@ function add(tools, definition) {
|
|
|
168
173
|
})
|
|
169
174
|
}
|
|
170
175
|
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Output half: the DSH-only shape, keyed by tool name
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
const OUTPUTS = {
|
|
181
|
+
ensp_status: {
|
|
182
|
+
schema: obj({
|
|
183
|
+
running: req(bool()),
|
|
184
|
+
pid: req(num()),
|
|
185
|
+
hwnd: req(str()),
|
|
186
|
+
anchors: req(str()),
|
|
187
|
+
devices: req(str()),
|
|
188
|
+
error: req(str()),
|
|
189
|
+
}),
|
|
190
|
+
render(args, value) {
|
|
191
|
+
if (!value.running) return text('eNSP is not running.')
|
|
192
|
+
const lines = [`eNSP running, pid ${value.pid}, hwnd ${value.hwnd || '(no frame)'}`]
|
|
193
|
+
if (value.anchors) lines.push(value.anchors)
|
|
194
|
+
if (value.devices) lines.push('', value.devices)
|
|
195
|
+
if (value.error) lines.push('', value.error)
|
|
196
|
+
return text(lines.join('\n'))
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
ensp_build_topology: {
|
|
201
|
+
schema: obj({
|
|
202
|
+
path: req(str()),
|
|
203
|
+
devices: req(num()),
|
|
204
|
+
links: req(num()),
|
|
205
|
+
consoles: req(str()),
|
|
206
|
+
error: req(str()),
|
|
207
|
+
}),
|
|
208
|
+
render(args, value) {
|
|
209
|
+
if (value.error) return text(value.error)
|
|
210
|
+
return text(
|
|
211
|
+
`wrote ${value.path}\n${value.devices} devices, ${value.links} links\nconsoles: ${value.consoles}`,
|
|
212
|
+
)
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
ensp_open: {
|
|
217
|
+
schema: obj({
|
|
218
|
+
pid: req(num()),
|
|
219
|
+
hwnd: req(str()),
|
|
220
|
+
reused: req(bool()),
|
|
221
|
+
error: req(str()),
|
|
222
|
+
}),
|
|
223
|
+
render(args, value) {
|
|
224
|
+
return text(`eNSP pid ${value.pid}, hwnd ${value.hwnd}${value.reused ? ' (reused)' : ' (launched)'}`)
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
ensp_start: {
|
|
229
|
+
schema: obj({
|
|
230
|
+
allReady: req(bool()),
|
|
231
|
+
ready: req(num()),
|
|
232
|
+
total: req(num()),
|
|
233
|
+
anchors: req(str()),
|
|
234
|
+
devices: req(str()),
|
|
235
|
+
error: req(str()),
|
|
236
|
+
}),
|
|
237
|
+
render(args, value) {
|
|
238
|
+
if (value.error) return text(value.error)
|
|
239
|
+
const head = `${value.ready}/${value.total} devices ready${value.allReady ? '' : ' (some did not answer)'}`
|
|
240
|
+
return text([head, value.anchors, '', value.devices].filter(Boolean).join('\n'))
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
ensp_stop: {
|
|
245
|
+
schema: obj({
|
|
246
|
+
ok: req(bool()),
|
|
247
|
+
anchors: req(str()),
|
|
248
|
+
error: req(str()),
|
|
249
|
+
}),
|
|
250
|
+
render(args, value) {
|
|
251
|
+
if (value.error) return text(value.error)
|
|
252
|
+
return text(`shutdown requested for all devices\n${value.anchors}`)
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
ensp_apply: {
|
|
257
|
+
schema: obj({
|
|
258
|
+
ok: req(bool()),
|
|
259
|
+
applied: req(num()),
|
|
260
|
+
total: req(num()),
|
|
261
|
+
saved: req(bool()),
|
|
262
|
+
failedAt: req(str()),
|
|
263
|
+
error: req(str()),
|
|
264
|
+
}),
|
|
265
|
+
render(args, value) {
|
|
266
|
+
if (value.error) return text(value.error)
|
|
267
|
+
if (value.ok) {
|
|
268
|
+
return text(`${args.device}: ${value.applied}/${value.total} commands applied${value.saved ? ', saved' : ''}`)
|
|
269
|
+
}
|
|
270
|
+
return text(`${args.device}: stopped after ${value.applied}/${value.total} commands\n${value.failedAt}`)
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
ensp_apply_dir: {
|
|
275
|
+
schema: obj({
|
|
276
|
+
configured: req(num()),
|
|
277
|
+
total: req(num()),
|
|
278
|
+
results: req(str()),
|
|
279
|
+
error: req(str()),
|
|
280
|
+
}),
|
|
281
|
+
render(args, value) {
|
|
282
|
+
if (value.error) return text(value.error)
|
|
283
|
+
return text(`${value.configured}/${value.total} devices configured\n\n${value.results}`)
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
ensp_query: {
|
|
288
|
+
schema: obj({
|
|
289
|
+
ok: req(bool()),
|
|
290
|
+
output: req(str()),
|
|
291
|
+
error: req(str()),
|
|
292
|
+
}),
|
|
293
|
+
render(args, value) {
|
|
294
|
+
if (!value.ok) return text(`${args.command} failed: ${value.error ?? 'unknown error'}`)
|
|
295
|
+
return text(value.output)
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
ensp_probe: {
|
|
300
|
+
schema: obj({
|
|
301
|
+
window: req(str()),
|
|
302
|
+
start: req(str()),
|
|
303
|
+
stop: req(str()),
|
|
304
|
+
method: req(str()),
|
|
305
|
+
clusters: req(str()),
|
|
306
|
+
error: req(str()),
|
|
307
|
+
}),
|
|
308
|
+
render(args, value) {
|
|
309
|
+
if (value.error) return text(value.error)
|
|
310
|
+
return text(
|
|
311
|
+
`window ${value.window}\nanchors (${value.method}): start ${value.start}, stop ${value.stop}\n\n${value.clusters}`,
|
|
312
|
+
)
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
ensp_show: {
|
|
317
|
+
schema: obj({
|
|
318
|
+
found: req(bool()),
|
|
319
|
+
restored: req(bool()),
|
|
320
|
+
raised: req(bool()),
|
|
321
|
+
foreground: req(str()),
|
|
322
|
+
error: req(str()),
|
|
323
|
+
}),
|
|
324
|
+
render(args, value) {
|
|
325
|
+
if (value.error) return text(value.error)
|
|
326
|
+
if (!value.found) return text('eNSP window not found.')
|
|
327
|
+
const lines = [
|
|
328
|
+
value.restored ? 'window restored from the taskbar' : 'window was not minimised',
|
|
329
|
+
value.raised ? 'window is in front' : 'window could not be raised; another window still holds the foreground',
|
|
330
|
+
]
|
|
331
|
+
return text(lines.join('\n'))
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
ensp_scenarios: {
|
|
336
|
+
schema: obj({
|
|
337
|
+
count: req(num()),
|
|
338
|
+
list: req(str()),
|
|
339
|
+
error: req(str()),
|
|
340
|
+
}),
|
|
341
|
+
render(args, value) {
|
|
342
|
+
return text(value.list || '(no scenario matched)')
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
ensp_scenario: {
|
|
347
|
+
schema: obj({
|
|
348
|
+
id: req(str()),
|
|
349
|
+
brief: req(str()),
|
|
350
|
+
curriculum: req(arr()),
|
|
351
|
+
error: req(str()),
|
|
352
|
+
}),
|
|
353
|
+
render(args, value) {
|
|
354
|
+
return text(value.brief)
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
ensp_images: {
|
|
359
|
+
schema: obj({
|
|
360
|
+
ready: req(bool()),
|
|
361
|
+
models: req(str()),
|
|
362
|
+
installed: req(str()),
|
|
363
|
+
error: req(str()),
|
|
364
|
+
}),
|
|
365
|
+
render(args, value) {
|
|
366
|
+
if (value.error) return text(value.error)
|
|
367
|
+
const head = value.installed ? `installed ${value.installed}\n\n` : ''
|
|
368
|
+
return text(head + value.models)
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
ensp_doctor: {
|
|
373
|
+
schema: obj({
|
|
374
|
+
ok: req(bool()),
|
|
375
|
+
failed: req(str()),
|
|
376
|
+
warned: req(str()),
|
|
377
|
+
skipped: req(str()),
|
|
378
|
+
checks: req(str()),
|
|
379
|
+
error: req(str()),
|
|
380
|
+
}),
|
|
381
|
+
render(args, value) {
|
|
382
|
+
if (value.error) return text(value.error)
|
|
383
|
+
return text(value.checks + '\n\n' + (value.ok ? 'environment ok' : 'environment NOT ready'))
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
ensp_verify: {
|
|
388
|
+
schema: obj({
|
|
389
|
+
scenario: req(str()),
|
|
390
|
+
ok: req(bool()),
|
|
391
|
+
verified: req(bool()),
|
|
392
|
+
summary: req(str()),
|
|
393
|
+
detail: req(str()),
|
|
394
|
+
error: req(str()),
|
|
395
|
+
}),
|
|
396
|
+
render(args, value) {
|
|
397
|
+
if (value.error) return text(value.error)
|
|
398
|
+
return text(`${value.scenario} · ${value.summary}\n\n${value.detail}`)
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
ensp_preset_config: {
|
|
403
|
+
schema: obj({
|
|
404
|
+
script: req(str()),
|
|
405
|
+
error: req(str()),
|
|
406
|
+
}),
|
|
407
|
+
render(args, value) {
|
|
408
|
+
return text(value.script)
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
ensp_report: {
|
|
413
|
+
schema: obj({
|
|
414
|
+
path: req(str()),
|
|
415
|
+
bytes: req(num()),
|
|
416
|
+
scenario: req(str()),
|
|
417
|
+
title: req(str()),
|
|
418
|
+
verdict: req(str()),
|
|
419
|
+
devices: req(num()),
|
|
420
|
+
checks: req(num()),
|
|
421
|
+
error: req(str()),
|
|
422
|
+
}),
|
|
423
|
+
render(args, value) {
|
|
424
|
+
if (value.error) return text(value.error)
|
|
425
|
+
const kb = Math.max(1, Math.round(value.bytes / 1024))
|
|
426
|
+
return text(
|
|
427
|
+
`${value.scenario} · ${value.title}\n\n` +
|
|
428
|
+
`wrote ${value.path} (${kb} kB)\n` +
|
|
429
|
+
`${value.devices} devices, ${value.checks} checks\n` +
|
|
430
|
+
`${value.verdict}`,
|
|
431
|
+
)
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// Execute half: returns the display-ready value the output schema describes
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
const EXEC = {
|
|
441
|
+
async ensp_status({ topology }) {
|
|
442
|
+
const pid = enspPid()
|
|
443
|
+
if (!pid) return { running: false }
|
|
444
|
+
const hwnd = locateWindow(pid)
|
|
445
|
+
let anchors = ''
|
|
446
|
+
if (hwnd) {
|
|
447
|
+
const a = calibrateAnchors(hwnd)
|
|
448
|
+
anchors = `toolbar anchors (${a.method}): start ${JSON.stringify(a.start)}, stop ${JSON.stringify(a.stop)}`
|
|
449
|
+
}
|
|
450
|
+
let devices = ''
|
|
451
|
+
if (topology) {
|
|
452
|
+
const topo = await readTopology(topology)
|
|
453
|
+
const rows = await Promise.all(
|
|
454
|
+
topo.devices
|
|
455
|
+
.filter((d) => d.comPort)
|
|
456
|
+
.map(async (d) => {
|
|
457
|
+
const r = await waitForConsole({ port: d.comPort, timeoutMs: 4000 })
|
|
458
|
+
return `${r.ready ? 'ready' : 'no answer'} ${d.name} :${d.comPort} ${r.prompt ?? ''}`.trimEnd()
|
|
459
|
+
}),
|
|
460
|
+
)
|
|
461
|
+
devices = rows.join('\n')
|
|
462
|
+
}
|
|
463
|
+
return { running: true, pid, hwnd: hwnd ?? '', anchors, devices }
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
async ensp_build_topology(args) {
|
|
467
|
+
if (!args.path) throw new Error('path is required')
|
|
468
|
+
let topo
|
|
469
|
+
if (args.scenario) {
|
|
470
|
+
if (!hasLab(args.scenario)) {
|
|
471
|
+
throw new Error(
|
|
472
|
+
`scenario "${args.scenario}" has no topology blueprint; ${LAB_IDS.length} of ${SCENARIO_IDS.length} have one`,
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
topo = buildScenarioTopology(args.scenario)
|
|
476
|
+
} else if (args.preset === 'two-pc' || (!args.devices && !args.links)) {
|
|
477
|
+
topo = twoPcLab({ vlan: args.vlan ?? 10, subnet: args.subnet ?? '192.168.10' })
|
|
478
|
+
} else {
|
|
479
|
+
const b = buildTopology()
|
|
480
|
+
for (const d of args.devices ?? []) b.add(d.name, d.model, { x: d.x, y: d.y, pc: d.pc })
|
|
481
|
+
for (const l of args.links ?? []) b.link(l.a, l.aPort, l.b, l.bPort)
|
|
482
|
+
topo = autoLayout(b.finish())
|
|
483
|
+
}
|
|
484
|
+
const target = path.resolve(args.path)
|
|
485
|
+
await writeTopology(target, topo)
|
|
486
|
+
const info = summarise(topo)
|
|
487
|
+
const detail = args.scenario
|
|
488
|
+
? '\n' + labDevices(args.scenario).join('\n')
|
|
489
|
+
: ''
|
|
490
|
+
const support = checkTopologySupport(topo)
|
|
491
|
+
const warning = support.ok
|
|
492
|
+
? ''
|
|
493
|
+
: '\nWARNING: this machine cannot start ' +
|
|
494
|
+
support.blocked.map((b) => `${b.name} (${b.model}: ${b.reason})`).join(', ') +
|
|
495
|
+
' -- eNSP will open the file and then quit'
|
|
496
|
+
return {
|
|
497
|
+
path: target,
|
|
498
|
+
devices: info.devices,
|
|
499
|
+
links: info.links,
|
|
500
|
+
consoles: JSON.stringify(info.consoles) + detail + warning,
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
async ensp_open({ topology }) {
|
|
505
|
+
if (!topology) throw new Error('topology is required')
|
|
506
|
+
const r = await launchEnsp({ topology: path.resolve(topology) })
|
|
507
|
+
return { pid: r.pid, hwnd: r.hwnd, reused: Boolean(r.reused) }
|
|
508
|
+
},
|
|
509
|
+
|
|
510
|
+
async ensp_start(args) {
|
|
511
|
+
if (!args.topology) throw new Error('topology is required')
|
|
512
|
+
const topo = await readTopology(path.resolve(args.topology))
|
|
513
|
+
const hwnd = args.hwnd ?? locateWindow(enspPid())
|
|
514
|
+
if (!hwnd) throw new Error('eNSP window not found; call ensp_open first')
|
|
515
|
+
const r = await startAllDevices({
|
|
516
|
+
devices: topo.devices,
|
|
517
|
+
hwnd,
|
|
518
|
+
perDeviceTimeoutMs: args.perDeviceTimeoutMs ?? 240000,
|
|
519
|
+
minimise: !args.keepWindow,
|
|
520
|
+
})
|
|
521
|
+
return {
|
|
522
|
+
allReady: r.allReady,
|
|
523
|
+
ready: r.ready,
|
|
524
|
+
total: r.total,
|
|
525
|
+
anchors: `toolbar anchors (${r.anchors?.method ?? 'default'}): ${JSON.stringify(r.anchors?.start ?? {})}`,
|
|
526
|
+
devices: r.devices
|
|
527
|
+
.map((d) => `${d.ready ? 'ready' : 'no answer'} ${d.name} :${d.port} ${d.prompt ?? ''}`.trimEnd())
|
|
528
|
+
.join('\n'),
|
|
529
|
+
}
|
|
530
|
+
},
|
|
531
|
+
|
|
532
|
+
async ensp_stop(args = {}) {
|
|
533
|
+
const hwnd = args.hwnd ?? locateWindow(enspPid())
|
|
534
|
+
if (!hwnd) throw new Error('eNSP window not found')
|
|
535
|
+
const r = stopAllDevices(hwnd)
|
|
536
|
+
return { ok: r.ok, anchors: `anchors (${r.anchors?.method ?? 'default'}): ${JSON.stringify(r.anchors?.stop ?? {})}` }
|
|
537
|
+
},
|
|
538
|
+
|
|
539
|
+
async ensp_apply(args) {
|
|
540
|
+
if (!args.port) throw new Error('port is required')
|
|
541
|
+
const r = await pushConfig({
|
|
542
|
+
device: args.device ?? 'device',
|
|
543
|
+
port: args.port,
|
|
544
|
+
script: args.script ?? '',
|
|
545
|
+
save: Boolean(args.save),
|
|
546
|
+
})
|
|
547
|
+
return {
|
|
548
|
+
ok: r.ok,
|
|
549
|
+
applied: r.applied,
|
|
550
|
+
total: r.total,
|
|
551
|
+
saved: r.saved,
|
|
552
|
+
failedAt: r.failedAt
|
|
553
|
+
? `line ${r.failedAt.line}: ${r.failedAt.command}\n ${r.failedAt.error}\n ${(r.failedAt.output ?? '').trim().split('\n').slice(-6).join('\n ')}`
|
|
554
|
+
: '',
|
|
555
|
+
}
|
|
556
|
+
},
|
|
557
|
+
|
|
558
|
+
async ensp_apply_dir(args) {
|
|
559
|
+
if (!args.directory || !args.topology) throw new Error('directory and topology are required')
|
|
560
|
+
const topo = await readTopology(path.resolve(args.topology))
|
|
561
|
+
const consoles = new Map(topo.devices.filter((d) => d.comPort).map((d) => [d.name, d.comPort]))
|
|
562
|
+
const files = (await readdir(args.directory)).filter((f) => /\.(cfg|txt)$/i.test(f))
|
|
563
|
+
const targets = []
|
|
564
|
+
for (const file of files) {
|
|
565
|
+
const device = file.replace(/\.(cfg|txt)$/i, '')
|
|
566
|
+
const port = consoles.get(device)
|
|
567
|
+
if (!port) continue
|
|
568
|
+
targets.push({ device, port, script: await readFile(path.join(args.directory, file), 'utf8') })
|
|
569
|
+
}
|
|
570
|
+
const results = await pushConfigMany({
|
|
571
|
+
targets,
|
|
572
|
+
concurrency: args.concurrency ?? 3,
|
|
573
|
+
save: Boolean(args.save),
|
|
574
|
+
})
|
|
575
|
+
return {
|
|
576
|
+
configured: results.filter((r) => r.ok).length,
|
|
577
|
+
total: results.length,
|
|
578
|
+
results: results
|
|
579
|
+
.map((r) =>
|
|
580
|
+
r.ok
|
|
581
|
+
? `ok ${r.device} ${r.applied}/${r.total}`
|
|
582
|
+
: `FAIL ${r.device} line ${r.failedAt?.line}: ${r.failedAt?.command} -> ${r.failedAt?.error}`,
|
|
583
|
+
)
|
|
584
|
+
.join('\n'),
|
|
585
|
+
}
|
|
586
|
+
},
|
|
587
|
+
|
|
588
|
+
async ensp_query(args) {
|
|
589
|
+
if (!args.port || !args.command) throw new Error('port and command are required')
|
|
590
|
+
const r = await queryDevice({ port: args.port, command: args.command })
|
|
591
|
+
return { ok: r.ok, output: r.output, error: r.error ?? '' }
|
|
592
|
+
},
|
|
593
|
+
|
|
594
|
+
async ensp_probe(args = {}) {
|
|
595
|
+
const hwnd = args.hwnd ?? locateWindow(enspPid())
|
|
596
|
+
if (!hwnd) throw new Error('eNSP window not found')
|
|
597
|
+
const r = inspectAnchors(hwnd)
|
|
598
|
+
const resolved = r.resolved ?? { start: {}, stop: {}, method: 'none' }
|
|
599
|
+
return {
|
|
600
|
+
window: r.window ?? '(unknown)',
|
|
601
|
+
start: JSON.stringify(resolved.start),
|
|
602
|
+
stop: JSON.stringify(resolved.stop),
|
|
603
|
+
method: resolved.method,
|
|
604
|
+
clusters: [
|
|
605
|
+
`green (${r.green.length}): ` + r.green.map((c) => `${c.x},${c.y} ${c.w}x${c.h}`).join(' | '),
|
|
606
|
+
`red (${r.red.length}): ` + r.red.map((c) => `${c.x},${c.y} ${c.w}x${c.h}`).join(' | '),
|
|
607
|
+
].join('\n'),
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
|
|
611
|
+
async ensp_show(args = {}) {
|
|
612
|
+
const hwnd = args.hwnd ?? locateWindow(enspPid())
|
|
613
|
+
if (!hwnd) throw new Error('eNSP window not found')
|
|
614
|
+
const r = showEnsp(hwnd)
|
|
615
|
+
return { ...r, foreground: r.foreground ?? '' }
|
|
616
|
+
},
|
|
617
|
+
|
|
618
|
+
async ensp_scenarios({ search, level, syllabus }) {
|
|
619
|
+
if (syllabus) {
|
|
620
|
+
const chapters = searchCurriculum(syllabus)
|
|
621
|
+
return {
|
|
622
|
+
count: chapters.length,
|
|
623
|
+
list: chapters
|
|
624
|
+
.map(
|
|
625
|
+
(c) =>
|
|
626
|
+
`${c.level} · ${c.title} (p${c.page})\n` +
|
|
627
|
+
(c.scenarios.length ? ` 场景: ${c.scenarios.join(', ')}\n` : '') +
|
|
628
|
+
(c.verify.length ? ` 验证: ${c.verify.slice(0, 4).join(' / ')}` : ''),
|
|
629
|
+
)
|
|
630
|
+
.join('\n'),
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const groups = byLevel()
|
|
634
|
+
let ids = level && groups[level] ? groups[level] : Object.keys(SCENARIOS)
|
|
635
|
+
if (search) {
|
|
636
|
+
const matched = new Set(searchScenarios(search).map((h) => h.id))
|
|
637
|
+
ids = ids.filter((id) => matched.has(id))
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
count: ids.length,
|
|
641
|
+
list: ids
|
|
642
|
+
.map((id) => `${id.padEnd(24)} ${SCENARIOS[id].title} [${SCENARIOS[id].level}]`)
|
|
643
|
+
.join('\n'),
|
|
644
|
+
}
|
|
645
|
+
},
|
|
646
|
+
|
|
647
|
+
async ensp_scenario({ id }) {
|
|
648
|
+
if (!SCENARIOS[id]) throw new Error(`unknown scenario "${id}"`)
|
|
649
|
+
const brief = hasLab(id)
|
|
650
|
+
? renderScenario(id) + `\n\n可生成拓扑: ensp_build_topology { path: "${id}.topo", scenario: "${id}" }`
|
|
651
|
+
: renderScenario(id)
|
|
652
|
+
return {
|
|
653
|
+
id,
|
|
654
|
+
brief:
|
|
655
|
+
brief +
|
|
656
|
+
(hasChecks(id)
|
|
657
|
+
? `\n\n验收: ensp_verify { scenario: "${id}", topology: "${id}.topo" }`
|
|
658
|
+
: '\n\n验收: 无专门断言,ensp_verify 会逐条跑场景里的验证命令') +
|
|
659
|
+
renderCurriculum(id),
|
|
660
|
+
curriculum: curriculumFor(id).map((c) => ({
|
|
661
|
+
level: c.level,
|
|
662
|
+
title: c.title,
|
|
663
|
+
page: c.page,
|
|
664
|
+
verify: c.verify,
|
|
665
|
+
})),
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
|
|
669
|
+
async ensp_images({ model, source, overwrite }) {
|
|
670
|
+
if (source) {
|
|
671
|
+
if (!model) throw new Error('model is required when installing an image')
|
|
672
|
+
const result = installPluginMedia(model, path.resolve(source), { overwrite: overwrite === true })
|
|
673
|
+
const line = result.bytes
|
|
674
|
+
? `${result.model} → ${result.target} (${result.bytes} bytes)`
|
|
675
|
+
: `${result.model} already has its image at ${result.target}`
|
|
676
|
+
return { ready: true, models: line, installed: result.bytes ? result.model : '', error: '' }
|
|
677
|
+
}
|
|
678
|
+
const models = model ? [model] : pluginModels()
|
|
679
|
+
const plans = models.map((name) => pluginMediaPlan(name))
|
|
680
|
+
const lines = plans.map((plan) => {
|
|
681
|
+
if (!plan.plugin) return `${plan.model}: not a plugin device in this installation`
|
|
682
|
+
if (plan.ready) {
|
|
683
|
+
const present = plan.media.find((item) => item.present)
|
|
684
|
+
return `${plan.model}: ready (${present.relative})`
|
|
685
|
+
}
|
|
686
|
+
return `${plan.model}: missing ${plan.target ?? 'no template'}`
|
|
687
|
+
})
|
|
688
|
+
return {
|
|
689
|
+
ready: plans.every((plan) => plan.ready),
|
|
690
|
+
models: lines.join('\n'),
|
|
691
|
+
installed: '',
|
|
692
|
+
error: '',
|
|
693
|
+
}
|
|
694
|
+
},
|
|
695
|
+
|
|
696
|
+
async ensp_doctor({ topology, deep }) {
|
|
697
|
+
let models
|
|
698
|
+
if (topology) models = modelsOf(await readTopology(path.resolve(topology)))
|
|
699
|
+
const report = doctor({ models, deep: deep !== false })
|
|
700
|
+
const lines = report.checks.map((c) => {
|
|
701
|
+
const tag = c.status === 'pass' ? '[ok ]'
|
|
702
|
+
: c.status === 'warn' ? '[warn]'
|
|
703
|
+
: c.status === 'skip' ? '[skip]'
|
|
704
|
+
: '[FAIL]'
|
|
705
|
+
return `${tag} ${c.id}: ${c.detail}` + (c.fix ? `\n fix: ${c.fix}` : '')
|
|
706
|
+
})
|
|
707
|
+
return {
|
|
708
|
+
ok: report.ok,
|
|
709
|
+
failed: report.failed.join(', ') || '(none)',
|
|
710
|
+
warned: report.warned.join(', ') || '(none)',
|
|
711
|
+
skipped: report.skipped.join(', ') || '(none)',
|
|
712
|
+
checks: lines.join('\n'),
|
|
713
|
+
}
|
|
714
|
+
},
|
|
715
|
+
|
|
716
|
+
async ensp_verify({ scenario, topology, timeout, wait }) {
|
|
717
|
+
if (!scenario) throw new Error('scenario is required')
|
|
718
|
+
if (!topology) throw new Error('topology is required: pass the .topo file that is running')
|
|
719
|
+
const report = await runAcceptance({
|
|
720
|
+
id: scenario,
|
|
721
|
+
topology: path.resolve(topology),
|
|
722
|
+
timeoutMs: timeout,
|
|
723
|
+
waitMs: wait,
|
|
724
|
+
})
|
|
725
|
+
const detail = report.results
|
|
726
|
+
.map((r) => {
|
|
727
|
+
const mark = r.pass === true ? 'PASS' : r.pass === null ? 'SKIP' : 'FAIL'
|
|
728
|
+
const waited = r.pass === true && r.waitedMs ? ` (converged after ${Math.round(r.waitedMs / 1000)}s)` : ''
|
|
729
|
+
return `${mark} ${r.device ?? '(no device)'} · ${r.id}${waited}: ${r.why}`
|
|
730
|
+
})
|
|
731
|
+
.join('\n')
|
|
732
|
+
return {
|
|
733
|
+
scenario: report.scenario,
|
|
734
|
+
ok: report.ok === true,
|
|
735
|
+
verified: report.ok !== null,
|
|
736
|
+
summary:
|
|
737
|
+
report.ok === null
|
|
738
|
+
? `unverified: ${report.unverified}/${report.total} commands ran with no assertion (${report.source})`
|
|
739
|
+
: `${report.passed}/${report.total} checks passed (${report.source})`,
|
|
740
|
+
detail,
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
|
|
744
|
+
async ensp_preset_config({ vlan, scenario }) {
|
|
745
|
+
if (!scenario) return { script: twoPcLabConfig({ vlan: vlan ?? 10 }) }
|
|
746
|
+
if (!hasLabConfig(scenario)) {
|
|
747
|
+
throw new Error(`scenario "${scenario}" has no addressing plan; ${countLabConfigs()} of them have one`)
|
|
748
|
+
}
|
|
749
|
+
return { script: labConfigScript(scenario) }
|
|
750
|
+
},
|
|
751
|
+
|
|
752
|
+
async ensp_report({ scenario, topology, out, timeout, wait }) {
|
|
753
|
+
if (!scenario) throw new Error('scenario is required')
|
|
754
|
+
const result = await writeReport({ id: scenario, topology, out, timeoutMs: timeout, waitMs: wait })
|
|
755
|
+
const { verdict } = result.evidence
|
|
756
|
+
const verdictText =
|
|
757
|
+
verdict.ok === true
|
|
758
|
+
? `passed: ${verdict.passed}/${verdict.total} checks`
|
|
759
|
+
: verdict.ok === false
|
|
760
|
+
? `failed: ${verdict.failed}/${verdict.total} checks`
|
|
761
|
+
: `unverified: ${verdict.unverified}/${verdict.total} checks had no assertion`
|
|
762
|
+
return {
|
|
763
|
+
path: result.path,
|
|
764
|
+
bytes: result.bytes,
|
|
765
|
+
scenario: result.evidence.id,
|
|
766
|
+
title: result.evidence.title,
|
|
767
|
+
verdict: verdictText,
|
|
768
|
+
devices: result.evidence.devices.length,
|
|
769
|
+
checks: result.evidence.checks.length,
|
|
770
|
+
}
|
|
771
|
+
},
|
|
772
|
+
}
|
|
773
|
+
|
|
171
774
|
/**
|
|
172
775
|
* Build the plugin.
|
|
173
776
|
*
|
|
@@ -180,653 +783,17 @@ export function apply(ctx) {
|
|
|
180
783
|
return
|
|
181
784
|
}
|
|
182
785
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
schema: obj({
|
|
195
|
-
running: req(bool()),
|
|
196
|
-
pid: req(num()),
|
|
197
|
-
hwnd: req(str()),
|
|
198
|
-
anchors: req(str()),
|
|
199
|
-
devices: req(str()),
|
|
200
|
-
error: req(str()),
|
|
201
|
-
}),
|
|
202
|
-
render(args, value) {
|
|
203
|
-
if (!value.running) return text('eNSP is not running.')
|
|
204
|
-
const lines = [`eNSP running, pid ${value.pid}, hwnd ${value.hwnd || '(no frame)'}`]
|
|
205
|
-
if (value.anchors) lines.push(value.anchors)
|
|
206
|
-
if (value.devices) lines.push('', value.devices)
|
|
207
|
-
if (value.error) lines.push('', value.error)
|
|
208
|
-
return text(lines.join('\n'))
|
|
209
|
-
},
|
|
210
|
-
},
|
|
211
|
-
async execute({ topology }) {
|
|
212
|
-
const pid = enspPid()
|
|
213
|
-
if (!pid) return { running: false }
|
|
214
|
-
const hwnd = locateWindow(pid)
|
|
215
|
-
let anchors = ''
|
|
216
|
-
if (hwnd) {
|
|
217
|
-
const a = calibrateAnchors(hwnd)
|
|
218
|
-
anchors = `toolbar anchors (${a.method}): start ${JSON.stringify(a.start)}, stop ${JSON.stringify(a.stop)}`
|
|
219
|
-
}
|
|
220
|
-
let devices = ''
|
|
221
|
-
if (topology) {
|
|
222
|
-
const topo = await readTopology(topology)
|
|
223
|
-
const rows = await Promise.all(
|
|
224
|
-
topo.devices
|
|
225
|
-
.filter((d) => d.comPort)
|
|
226
|
-
.map(async (d) => {
|
|
227
|
-
const r = await waitForConsole({ port: d.comPort, timeoutMs: 4000 })
|
|
228
|
-
return `${r.ready ? 'ready' : 'no answer'} ${d.name} :${d.comPort} ${r.prompt ?? ''}`.trimEnd()
|
|
229
|
-
}),
|
|
230
|
-
)
|
|
231
|
-
devices = rows.join('\n')
|
|
232
|
-
}
|
|
233
|
-
return { running: true, pid, hwnd: hwnd ?? '', anchors, devices }
|
|
234
|
-
},
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
/* -------------------------------------------------------------- build topo */
|
|
238
|
-
|
|
239
|
-
add(tools, {
|
|
240
|
-
name: 'ensp_build_topology',
|
|
241
|
-
description:
|
|
242
|
-
'Generate a .topo file eNSP can open. Use preset "two-pc" for the built-in two-PC lab, '
|
|
243
|
-
+ 'scenario for the topology behind a scenario id, or pass devices and links (links use VRP '
|
|
244
|
-
+ 'interface names such as GigabitEthernet0/0/0).',
|
|
245
|
-
parameters: {
|
|
246
|
-
path: str('Output .topo path.'),
|
|
247
|
-
preset: str('"two-pc" for the built-in lab.'),
|
|
248
|
-
scenario: str('Scenario id with a topology blueprint, e.g. "ospf-single".'),
|
|
249
|
-
vlan: num('VLAN id for the two-pc preset (default 10).'),
|
|
250
|
-
devices: arr('Device list: [{ name, model, x, y }].'),
|
|
251
|
-
links: arr('Link list: [{ a, aPort, b, bPort }].'),
|
|
252
|
-
},
|
|
253
|
-
output: {
|
|
254
|
-
schema: obj({
|
|
255
|
-
path: req(str()),
|
|
256
|
-
devices: req(num()),
|
|
257
|
-
links: req(num()),
|
|
258
|
-
consoles: req(str()),
|
|
259
|
-
error: req(str()),
|
|
260
|
-
}),
|
|
261
|
-
render(args, value) {
|
|
262
|
-
if (value.error) return text(value.error)
|
|
263
|
-
return text(
|
|
264
|
-
`wrote ${value.path}\n${value.devices} devices, ${value.links} links\nconsoles: ${value.consoles}`,
|
|
265
|
-
)
|
|
266
|
-
},
|
|
267
|
-
},
|
|
268
|
-
async execute(args) {
|
|
269
|
-
if (!args.path) throw new Error('path is required')
|
|
270
|
-
let topo
|
|
271
|
-
if (args.scenario) {
|
|
272
|
-
if (!hasLab(args.scenario)) {
|
|
273
|
-
throw new Error(
|
|
274
|
-
`scenario "${args.scenario}" has no topology blueprint; ${LAB_IDS.length} of ${SCENARIO_IDS.length} have one`,
|
|
275
|
-
)
|
|
276
|
-
}
|
|
277
|
-
topo = buildScenarioTopology(args.scenario)
|
|
278
|
-
} else if (args.preset === 'two-pc' || (!args.devices && !args.links)) {
|
|
279
|
-
topo = twoPcLab({ vlan: args.vlan ?? 10 })
|
|
280
|
-
} else {
|
|
281
|
-
const b = buildTopology()
|
|
282
|
-
for (const d of args.devices ?? []) b.add(d.name, d.model, { x: d.x, y: d.y, pc: d.pc })
|
|
283
|
-
for (const l of args.links ?? []) b.link(l.a, l.aPort, l.b, l.bPort)
|
|
284
|
-
topo = autoLayout(b.finish())
|
|
285
|
-
}
|
|
286
|
-
const target = path.resolve(args.path)
|
|
287
|
-
await writeTopology(target, topo)
|
|
288
|
-
const info = summarise(topo)
|
|
289
|
-
const detail = args.scenario
|
|
290
|
-
? '\n' + labDevices(args.scenario).join('\n')
|
|
291
|
-
: ''
|
|
292
|
-
const support = checkTopologySupport(topo)
|
|
293
|
-
const warning = support.ok
|
|
294
|
-
? ''
|
|
295
|
-
: '\nWARNING: this machine cannot start ' +
|
|
296
|
-
support.blocked.map((b) => `${b.name} (${b.model}: ${b.reason})`).join(', ') +
|
|
297
|
-
' -- eNSP will open the file and then quit'
|
|
298
|
-
return {
|
|
299
|
-
path: target,
|
|
300
|
-
devices: info.devices,
|
|
301
|
-
links: info.links,
|
|
302
|
-
consoles: JSON.stringify(info.consoles) + detail + warning,
|
|
303
|
-
}
|
|
304
|
-
},
|
|
305
|
-
})
|
|
306
|
-
|
|
307
|
-
/* ------------------------------------------------------------------- open */
|
|
308
|
-
|
|
309
|
-
add(tools, {
|
|
310
|
-
name: 'ensp_open',
|
|
311
|
-
description: 'Launch the eNSP client (or reuse the running one) and open a topology file.',
|
|
312
|
-
parameters: { topology: str('Absolute path to a .topo file.') },
|
|
313
|
-
output: {
|
|
314
|
-
schema: obj({
|
|
315
|
-
pid: req(num()),
|
|
316
|
-
hwnd: req(str()),
|
|
317
|
-
reused: req(bool()),
|
|
318
|
-
error: req(str()),
|
|
319
|
-
}),
|
|
320
|
-
render(args, value) {
|
|
321
|
-
return text(`eNSP pid ${value.pid}, hwnd ${value.hwnd}${value.reused ? ' (reused)' : ' (launched)'}`)
|
|
322
|
-
},
|
|
323
|
-
},
|
|
324
|
-
async execute({ topology }) {
|
|
325
|
-
if (!topology) throw new Error('topology is required')
|
|
326
|
-
const r = await launchEnsp({ topology: path.resolve(topology) })
|
|
327
|
-
return { pid: r.pid, hwnd: r.hwnd, reused: Boolean(r.reused) }
|
|
328
|
-
},
|
|
329
|
-
})
|
|
330
|
-
|
|
331
|
-
/* ------------------------------------------------------------------ start */
|
|
332
|
-
|
|
333
|
-
add(tools, {
|
|
334
|
-
name: 'ensp_start',
|
|
335
|
-
description:
|
|
336
|
-
'Boot every device in a topology. Calibrates the toolbar by colour, clicks start, then polls each '
|
|
337
|
-
+ 'console until VRP answers, so readiness is measured per device rather than assumed. The window is '
|
|
338
|
-
+ 'minimised afterwards unless keepWindow is true.',
|
|
339
|
-
parameters: {
|
|
340
|
-
topology: str('Absolute path to the .topo file that is currently open.'),
|
|
341
|
-
perDeviceTimeoutMs: num('Per-device readiness ceiling in ms (default 240000).'),
|
|
342
|
-
keepWindow: bool('Leave the eNSP window visible after starting.'),
|
|
343
|
-
},
|
|
344
|
-
output: {
|
|
345
|
-
schema: obj({
|
|
346
|
-
allReady: req(bool()),
|
|
347
|
-
ready: req(num()),
|
|
348
|
-
total: req(num()),
|
|
349
|
-
anchors: req(str()),
|
|
350
|
-
devices: req(str()),
|
|
351
|
-
error: req(str()),
|
|
352
|
-
}),
|
|
353
|
-
render(args, value) {
|
|
354
|
-
if (value.error) return text(value.error)
|
|
355
|
-
const head = `${value.ready}/${value.total} devices ready${value.allReady ? '' : ' (some did not answer)'}`
|
|
356
|
-
return text([head, value.anchors, '', value.devices].filter(Boolean).join('\n'))
|
|
357
|
-
},
|
|
358
|
-
},
|
|
359
|
-
async execute(args) {
|
|
360
|
-
if (!args.topology) throw new Error('topology is required')
|
|
361
|
-
const topo = await readTopology(path.resolve(args.topology))
|
|
362
|
-
const hwnd = locateWindow(enspPid())
|
|
363
|
-
if (!hwnd) throw new Error('eNSP window not found; call ensp_open first')
|
|
364
|
-
const r = await startAllDevices({
|
|
365
|
-
devices: topo.devices,
|
|
366
|
-
hwnd,
|
|
367
|
-
perDeviceTimeoutMs: args.perDeviceTimeoutMs ?? 240000,
|
|
368
|
-
minimise: !args.keepWindow,
|
|
369
|
-
})
|
|
370
|
-
return {
|
|
371
|
-
allReady: r.allReady,
|
|
372
|
-
ready: r.ready,
|
|
373
|
-
total: r.total,
|
|
374
|
-
anchors: `toolbar anchors (${r.anchors?.method ?? 'default'}): ${JSON.stringify(r.anchors?.start ?? {})}`,
|
|
375
|
-
devices: r.devices
|
|
376
|
-
.map((d) => `${d.ready ? 'ready' : 'no answer'} ${d.name} :${d.port} ${d.prompt ?? ''}`.trimEnd())
|
|
377
|
-
.join('\n'),
|
|
378
|
-
}
|
|
379
|
-
},
|
|
380
|
-
})
|
|
381
|
-
|
|
382
|
-
/* ------------------------------------------------------------------- stop */
|
|
383
|
-
|
|
384
|
-
add(tools, {
|
|
385
|
-
name: 'ensp_stop',
|
|
386
|
-
description: 'Shut down every device in the running topology.',
|
|
387
|
-
parameters: {},
|
|
388
|
-
output: {
|
|
389
|
-
schema: obj({
|
|
390
|
-
ok: req(bool()),
|
|
391
|
-
anchors: req(str()),
|
|
392
|
-
error: req(str()),
|
|
393
|
-
}),
|
|
394
|
-
render(args, value) {
|
|
395
|
-
if (value.error) return text(value.error)
|
|
396
|
-
return text(`shutdown requested for all devices\n${value.anchors}`)
|
|
397
|
-
},
|
|
398
|
-
},
|
|
399
|
-
async execute() {
|
|
400
|
-
const hwnd = locateWindow(enspPid())
|
|
401
|
-
if (!hwnd) throw new Error('eNSP window not found')
|
|
402
|
-
const r = stopAllDevices(hwnd)
|
|
403
|
-
return { ok: r.ok, anchors: `anchors (${r.anchors?.method ?? 'default'}): ${JSON.stringify(r.anchors?.stop ?? {})}` }
|
|
404
|
-
},
|
|
405
|
-
})
|
|
406
|
-
|
|
407
|
-
/* ------------------------------------------------------------------ apply */
|
|
408
|
-
|
|
409
|
-
add(tools, {
|
|
410
|
-
name: 'ensp_apply',
|
|
411
|
-
description:
|
|
412
|
-
'Push a configuration script to one device, one command at a time. Each command is confirmed by its '
|
|
413
|
-
+ 'prompt before the next is sent; the first error aborts the batch and reports the line, the command '
|
|
414
|
-
+ 'and the raw device output.',
|
|
415
|
-
parameters: {
|
|
416
|
-
device: str('Device name, used in the report.'),
|
|
417
|
-
port: num('Console TCP port from the .topo file.'),
|
|
418
|
-
script: str('Configuration text; blank lines and ! / # comments are skipped.'),
|
|
419
|
-
save: bool('Run save at the end.'),
|
|
420
|
-
},
|
|
421
|
-
output: {
|
|
422
|
-
schema: obj({
|
|
423
|
-
ok: req(bool()),
|
|
424
|
-
applied: req(num()),
|
|
425
|
-
total: req(num()),
|
|
426
|
-
saved: req(bool()),
|
|
427
|
-
failedAt: req(str()),
|
|
428
|
-
error: req(str()),
|
|
429
|
-
}),
|
|
430
|
-
render(args, value) {
|
|
431
|
-
if (value.error) return text(value.error)
|
|
432
|
-
if (value.ok) {
|
|
433
|
-
return text(`${args.device}: ${value.applied}/${value.total} commands applied${value.saved ? ', saved' : ''}`)
|
|
434
|
-
}
|
|
435
|
-
return text(`${args.device}: stopped after ${value.applied}/${value.total} commands\n${value.failedAt}`)
|
|
436
|
-
},
|
|
437
|
-
},
|
|
438
|
-
async execute(args) {
|
|
439
|
-
if (!args.port) throw new Error('port is required')
|
|
440
|
-
const r = await pushConfig({
|
|
441
|
-
device: args.device ?? 'device',
|
|
442
|
-
port: args.port,
|
|
443
|
-
script: args.script ?? '',
|
|
444
|
-
save: Boolean(args.save),
|
|
445
|
-
})
|
|
446
|
-
return {
|
|
447
|
-
ok: r.ok,
|
|
448
|
-
applied: r.applied,
|
|
449
|
-
total: r.total,
|
|
450
|
-
saved: r.saved,
|
|
451
|
-
failedAt: r.failedAt
|
|
452
|
-
? `line ${r.failedAt.line}: ${r.failedAt.command}\n ${r.failedAt.error}\n ${(r.failedAt.output ?? '').trim().split('\n').slice(-6).join('\n ')}`
|
|
453
|
-
: '',
|
|
454
|
-
}
|
|
455
|
-
},
|
|
456
|
-
})
|
|
457
|
-
|
|
458
|
-
/* -------------------------------------------------------------- apply dir */
|
|
459
|
-
|
|
460
|
-
add(tools, {
|
|
461
|
-
name: 'ensp_apply_dir',
|
|
462
|
-
description:
|
|
463
|
-
'Push one .cfg per device from a directory, matching file names to device names. Devices are '
|
|
464
|
-
+ 'configured concurrently and one failure does not stop the others.',
|
|
465
|
-
parameters: {
|
|
466
|
-
directory: str('Directory containing <device>.cfg files.'),
|
|
467
|
-
topology: str('Topology that maps device names to console ports.'),
|
|
468
|
-
save: bool('Run save on each device afterwards.'),
|
|
469
|
-
concurrency: num('How many devices to configure at once (default 3).'),
|
|
470
|
-
},
|
|
471
|
-
output: {
|
|
472
|
-
schema: obj({
|
|
473
|
-
configured: req(num()),
|
|
474
|
-
total: req(num()),
|
|
475
|
-
results: req(str()),
|
|
476
|
-
error: req(str()),
|
|
477
|
-
}),
|
|
478
|
-
render(args, value) {
|
|
479
|
-
if (value.error) return text(value.error)
|
|
480
|
-
return text(`${value.configured}/${value.total} devices configured\n\n${value.results}`)
|
|
481
|
-
},
|
|
482
|
-
},
|
|
483
|
-
async execute(args) {
|
|
484
|
-
if (!args.directory || !args.topology) throw new Error('directory and topology are required')
|
|
485
|
-
const topo = await readTopology(path.resolve(args.topology))
|
|
486
|
-
const consoles = new Map(topo.devices.filter((d) => d.comPort).map((d) => [d.name, d.comPort]))
|
|
487
|
-
const files = (await readdir(args.directory)).filter((f) => /\.(cfg|txt)$/i.test(f))
|
|
488
|
-
const targets = []
|
|
489
|
-
for (const file of files) {
|
|
490
|
-
const device = file.replace(/\.(cfg|txt)$/i, '')
|
|
491
|
-
const port = consoles.get(device)
|
|
492
|
-
if (!port) continue
|
|
493
|
-
targets.push({ device, port, script: await readFile(path.join(args.directory, file), 'utf8') })
|
|
494
|
-
}
|
|
495
|
-
const results = await pushConfigMany({
|
|
496
|
-
targets,
|
|
497
|
-
concurrency: args.concurrency ?? 3,
|
|
498
|
-
save: Boolean(args.save),
|
|
499
|
-
})
|
|
500
|
-
return {
|
|
501
|
-
configured: results.filter((r) => r.ok).length,
|
|
502
|
-
total: results.length,
|
|
503
|
-
results: results
|
|
504
|
-
.map((r) =>
|
|
505
|
-
r.ok
|
|
506
|
-
? `ok ${r.device} ${r.applied}/${r.total}`
|
|
507
|
-
: `FAIL ${r.device} line ${r.failedAt?.line}: ${r.failedAt?.command} -> ${r.failedAt?.error}`,
|
|
508
|
-
)
|
|
509
|
-
.join('\n'),
|
|
510
|
-
}
|
|
511
|
-
},
|
|
512
|
-
})
|
|
513
|
-
|
|
514
|
-
/* ------------------------------------------------------------------ query */
|
|
515
|
-
|
|
516
|
-
add(tools, {
|
|
517
|
-
name: 'ensp_query',
|
|
518
|
-
description: 'Run one read-only command on a device console and return its output.',
|
|
519
|
-
parameters: {
|
|
520
|
-
port: num('Console TCP port.'),
|
|
521
|
-
command: str('One VRP command, e.g. "display ip routing-table".'),
|
|
522
|
-
},
|
|
523
|
-
output: {
|
|
524
|
-
schema: obj({
|
|
525
|
-
ok: req(bool()),
|
|
526
|
-
output: req(str()),
|
|
527
|
-
error: req(str()),
|
|
528
|
-
}),
|
|
529
|
-
render(args, value) {
|
|
530
|
-
if (!value.ok) return text(`${args.command} failed: ${value.error ?? 'unknown error'}`)
|
|
531
|
-
return text(value.output)
|
|
532
|
-
},
|
|
533
|
-
},
|
|
534
|
-
async execute(args) {
|
|
535
|
-
if (!args.port || !args.command) throw new Error('port and command are required')
|
|
536
|
-
const r = await queryDevice({ port: args.port, command: args.command })
|
|
537
|
-
return { ok: r.ok, output: r.output, error: r.error ?? '' }
|
|
538
|
-
},
|
|
539
|
-
})
|
|
540
|
-
|
|
541
|
-
/* -------------------------------------------------------------- scenarios */
|
|
542
|
-
|
|
543
|
-
add(tools, {
|
|
544
|
-
name: 'ensp_scenarios',
|
|
545
|
-
description:
|
|
546
|
-
'List or search the ' + SCENARIO_IDS.length + ' built-in configuration scenarios. Each is a skeleton: what to create, in what '
|
|
547
|
-
+ 'order, and the command that proves it worked, rather than a command dump.',
|
|
548
|
-
parameters: {
|
|
549
|
-
search: str('Keyword filter, e.g. "ospf" or "vlan".'),
|
|
550
|
-
level: str('One of basic, intermediate, advanced.'),
|
|
551
|
-
},
|
|
552
|
-
output: {
|
|
553
|
-
schema: obj({
|
|
554
|
-
count: req(num()),
|
|
555
|
-
list: req(str()),
|
|
556
|
-
error: req(str()),
|
|
557
|
-
}),
|
|
558
|
-
render(args, value) {
|
|
559
|
-
return text(value.list || '(no scenario matched)')
|
|
560
|
-
},
|
|
561
|
-
},
|
|
562
|
-
async execute({ search, level }) {
|
|
563
|
-
const groups = byLevel()
|
|
564
|
-
let ids = level && groups[level] ? groups[level] : Object.keys(SCENARIOS)
|
|
565
|
-
if (search) {
|
|
566
|
-
const matched = new Set(searchScenarios(search).map((h) => h.id))
|
|
567
|
-
ids = ids.filter((id) => matched.has(id))
|
|
568
|
-
}
|
|
569
|
-
return {
|
|
570
|
-
count: ids.length,
|
|
571
|
-
list: ids
|
|
572
|
-
.map((id) => `${id.padEnd(24)} ${SCENARIOS[id].title} [${SCENARIOS[id].level}]`)
|
|
573
|
-
.join('\n'),
|
|
574
|
-
}
|
|
575
|
-
},
|
|
576
|
-
})
|
|
577
|
-
|
|
578
|
-
add(tools, {
|
|
579
|
-
name: 'ensp_scenario',
|
|
580
|
-
description: 'Return one scenario as a step-by-step brief with verification commands and pitfalls.',
|
|
581
|
-
parameters: { id: str('Scenario id, e.g. "ospf-single".') },
|
|
582
|
-
output: {
|
|
583
|
-
schema: obj({
|
|
584
|
-
id: req(str()),
|
|
585
|
-
brief: req(str()),
|
|
586
|
-
curriculum: req(arr()),
|
|
587
|
-
error: req(str()),
|
|
588
|
-
}),
|
|
589
|
-
render(args, value) {
|
|
590
|
-
return text(value.brief)
|
|
591
|
-
},
|
|
592
|
-
},
|
|
593
|
-
async execute({ id }) {
|
|
594
|
-
if (!SCENARIOS[id]) throw new Error(`unknown scenario "${id}"`)
|
|
595
|
-
const brief = hasLab(id)
|
|
596
|
-
? renderScenario(id) + `\n\n可生成拓扑: ensp_build_topology { path: "${id}.topo", scenario: "${id}" }`
|
|
597
|
-
: renderScenario(id)
|
|
598
|
-
return {
|
|
599
|
-
id,
|
|
600
|
-
brief:
|
|
601
|
-
brief +
|
|
602
|
-
(hasChecks(id)
|
|
603
|
-
? `\n\n验收: ensp_verify { scenario: "${id}", topology: "${id}.topo" }`
|
|
604
|
-
: '\n\n验收: 无专门断言,ensp_verify 会逐条跑场景里的验证命令') +
|
|
605
|
-
renderCurriculum(id) +
|
|
606
|
-
renderKnowledge(id),
|
|
607
|
-
curriculum: curriculumFor(id).map((c) => ({
|
|
608
|
-
level: c.level,
|
|
609
|
-
title: c.title,
|
|
610
|
-
page: c.page,
|
|
611
|
-
verify: c.verify,
|
|
612
|
-
})),
|
|
613
|
-
}
|
|
614
|
-
},
|
|
615
|
-
})
|
|
616
|
-
|
|
617
|
-
/* ---------------------------------------------------------- preset config */
|
|
618
|
-
|
|
619
|
-
add(tools, {
|
|
620
|
-
name: 'ensp_knowledge',
|
|
621
|
-
description:
|
|
622
|
-
'Look up two reference indexes. Pass scenario to list what one lab teaches, query to ' +
|
|
623
|
-
'search short term/answer notes, or syllabus to search the vendor manual chapter index ' +
|
|
624
|
-
'(chapter goals plus the display commands each chapter uses to prove its result).',
|
|
625
|
-
parameters: {
|
|
626
|
-
query: str('Keyword, e.g. "LSA" or "MED".'),
|
|
627
|
-
scenario: str('Scenario id, e.g. "ospf-lsa-types".'),
|
|
628
|
-
syllabus: str('Keyword to search the manual chapter index, e.g. "MPLS" or "BFD".'),
|
|
629
|
-
},
|
|
630
|
-
output: {
|
|
631
|
-
schema: obj({
|
|
632
|
-
scope: req(str()),
|
|
633
|
-
count: req(num()),
|
|
634
|
-
points: req(str()),
|
|
635
|
-
error: req(str()),
|
|
636
|
-
}),
|
|
637
|
-
render(args, value) {
|
|
638
|
-
return text(value.points || '(no knowledge point matched)')
|
|
639
|
-
},
|
|
640
|
-
},
|
|
641
|
-
async execute({ query, scenario, syllabus }) {
|
|
642
|
-
if (syllabus) {
|
|
643
|
-
const chapters = searchCurriculum(syllabus)
|
|
644
|
-
return {
|
|
645
|
-
scope: `syllabus:${syllabus}`,
|
|
646
|
-
count: chapters.length,
|
|
647
|
-
points: chapters
|
|
648
|
-
.map(
|
|
649
|
-
(c) =>
|
|
650
|
-
`${c.level} · ${c.title} (p${c.page})\n` +
|
|
651
|
-
(c.scenarios.length ? ` 场景: ${c.scenarios.join(', ')}\n` : '') +
|
|
652
|
-
(c.verify.length ? ` 验证: ${c.verify.slice(0, 4).join(' / ')}` : ''),
|
|
653
|
-
)
|
|
654
|
-
.join('\n'),
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
if (scenario) {
|
|
658
|
-
if (!SCENARIOS[scenario]) throw new Error(`unknown scenario "${scenario}"`)
|
|
659
|
-
const points = knowledgeFor(scenario)
|
|
660
|
-
return {
|
|
661
|
-
scope: scenario,
|
|
662
|
-
count: points.length,
|
|
663
|
-
points: points.map((p) => `#${p.n} [${p.topic}] ${p.q}\n 答: ${p.a}`).join('\n'),
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
if (!query) throw new Error('pass query, scenario or syllabus')
|
|
667
|
-
const points = searchKnowledge(query)
|
|
668
|
-
return {
|
|
669
|
-
scope: String(query),
|
|
670
|
-
count: points.length,
|
|
671
|
-
points: points.map((p) => `#${p.n} [${p.topic}] ${p.q}\n 答: ${p.a}`).join('\n'),
|
|
672
|
-
}
|
|
673
|
-
},
|
|
674
|
-
})
|
|
675
|
-
|
|
676
|
-
add(tools, {
|
|
677
|
-
name: 'ensp_preset_config',
|
|
678
|
-
description: 'Return the configuration text for the built-in two-PC lab.',
|
|
679
|
-
parameters: { vlan: num('VLAN id (default 10).') },
|
|
680
|
-
output: {
|
|
681
|
-
schema: obj({
|
|
682
|
-
script: req(str()),
|
|
683
|
-
error: req(str()),
|
|
684
|
-
}),
|
|
685
|
-
render(args, value) {
|
|
686
|
-
return text(value.script)
|
|
687
|
-
},
|
|
688
|
-
},
|
|
689
|
-
async execute({ vlan }) {
|
|
690
|
-
return { script: twoPcLabConfig({ vlan: vlan ?? 10 }) }
|
|
691
|
-
},
|
|
692
|
-
})
|
|
693
|
-
|
|
694
|
-
/* ------------------------------------------------------------------ probe */
|
|
695
|
-
|
|
696
|
-
add(tools, {
|
|
697
|
-
name: 'ensp_probe',
|
|
698
|
-
description:
|
|
699
|
-
'Re-measure the toolbar anchors and report every colour cluster. Run this after an eNSP upgrade or a '
|
|
700
|
-
+ 'change of display scaling; results are cached per window size otherwise.',
|
|
701
|
-
parameters: {},
|
|
702
|
-
output: {
|
|
703
|
-
schema: obj({
|
|
704
|
-
window: req(str()),
|
|
705
|
-
start: req(str()),
|
|
706
|
-
stop: req(str()),
|
|
707
|
-
method: req(str()),
|
|
708
|
-
clusters: req(str()),
|
|
709
|
-
error: req(str()),
|
|
710
|
-
}),
|
|
711
|
-
render(args, value) {
|
|
712
|
-
if (value.error) return text(value.error)
|
|
713
|
-
return text(
|
|
714
|
-
`window ${value.window}\nanchors (${value.method}): start ${value.start}, stop ${value.stop}\n\n${value.clusters}`,
|
|
715
|
-
)
|
|
716
|
-
},
|
|
717
|
-
},
|
|
718
|
-
async execute() {
|
|
719
|
-
const hwnd = locateWindow(enspPid())
|
|
720
|
-
if (!hwnd) throw new Error('eNSP window not found')
|
|
721
|
-
const r = inspectAnchors(hwnd)
|
|
722
|
-
const resolved = r.resolved ?? { start: {}, stop: {}, method: 'none' }
|
|
723
|
-
return {
|
|
724
|
-
window: r.window ?? '(unknown)',
|
|
725
|
-
start: JSON.stringify(resolved.start),
|
|
726
|
-
stop: JSON.stringify(resolved.stop),
|
|
727
|
-
method: resolved.method,
|
|
728
|
-
clusters: [
|
|
729
|
-
`green (${r.green.length}): ` + r.green.map((c) => `${c.x},${c.y} ${c.w}x${c.h}`).join(' | '),
|
|
730
|
-
`red (${r.red.length}): ` + r.red.map((c) => `${c.x},${c.y} ${c.w}x${c.h}`).join(' | '),
|
|
731
|
-
].join('\n'),
|
|
732
|
-
}
|
|
733
|
-
},
|
|
734
|
-
})
|
|
735
|
-
|
|
736
|
-
/* ----------------------------------------------------------------- doctor */
|
|
737
|
-
|
|
738
|
-
add(tools, {
|
|
739
|
-
name: 'ensp_doctor',
|
|
740
|
-
description:
|
|
741
|
-
'Check whether this machine can actually run a lab before starting anything: eNSP and '
|
|
742
|
-
+ 'VirtualBox presence, missing device images, console ports already in use, free memory, and '
|
|
743
|
-
+ 'whether synthetic mouse input reaches this session at all. Every failing check comes with a '
|
|
744
|
-
+ 'fix. Never starts eNSP, so it is safe to run first.',
|
|
745
|
-
parameters: {
|
|
746
|
-
topology: str('optional .topo path, so the device images it needs can be checked'),
|
|
747
|
-
deep: bool('run the slow input-injection probe (default true)'),
|
|
748
|
-
},
|
|
749
|
-
output: {
|
|
750
|
-
schema: obj({
|
|
751
|
-
ok: req(bool()),
|
|
752
|
-
failed: req(str()),
|
|
753
|
-
warned: req(str()),
|
|
754
|
-
checks: req(str()),
|
|
755
|
-
error: req(str()),
|
|
756
|
-
}),
|
|
757
|
-
render(args, value) {
|
|
758
|
-
if (value.error) return text(value.error)
|
|
759
|
-
return text(value.checks + '\n\n' + (value.ok ? 'environment ok' : 'environment NOT ready'))
|
|
760
|
-
},
|
|
761
|
-
},
|
|
762
|
-
async execute({ topology, deep }) {
|
|
763
|
-
let models
|
|
764
|
-
if (topology) models = modelsOf(await readTopology(path.resolve(topology)))
|
|
765
|
-
const report = doctor({ models, deep: deep !== false })
|
|
766
|
-
const lines = report.checks.map((c) => {
|
|
767
|
-
const tag = c.status === 'pass' ? '[ok ]' : c.status === 'warn' ? '[warn]' : '[FAIL]'
|
|
768
|
-
return `${tag} ${c.id}: ${c.detail}` + (c.fix ? `\n fix: ${c.fix}` : '')
|
|
769
|
-
})
|
|
770
|
-
return {
|
|
771
|
-
ok: report.ok,
|
|
772
|
-
failed: report.failed.join(', ') || '(none)',
|
|
773
|
-
warned: report.warned.join(', ') || '(none)',
|
|
774
|
-
checks: lines.join('\n'),
|
|
775
|
-
}
|
|
776
|
-
},
|
|
777
|
-
})
|
|
778
|
-
|
|
779
|
-
/* ----------------------------------------------------------------- verify */
|
|
780
|
-
|
|
781
|
-
add(tools, {
|
|
782
|
-
name: 'ensp_verify',
|
|
783
|
-
description:
|
|
784
|
-
'Run a scenario\'s acceptance checks against a lab that is already running. Curated scenarios '
|
|
785
|
-
+ 'assert on what each device reports (OSPF Full, BGP Established, VRRP Master); the rest '
|
|
786
|
-
+ 'smoke-test every verification command in the scenario. Use this to decide whether the lab '
|
|
787
|
-
+ 'works, rather than whether configuration was merely sent.',
|
|
788
|
-
parameters: {
|
|
789
|
-
scenario: str('scenario id, e.g. "ospf-single"'),
|
|
790
|
-
topology: str('the .topo file that is running, used for console ports'),
|
|
791
|
-
timeout: num('per-command timeout in ms (default 25000)'),
|
|
792
|
-
wait: num('how long a failing assertion may keep being retried, in ms (default 60000)'),
|
|
793
|
-
},
|
|
794
|
-
output: {
|
|
795
|
-
schema: obj({
|
|
796
|
-
scenario: req(str()),
|
|
797
|
-
ok: req(bool()),
|
|
798
|
-
summary: req(str()),
|
|
799
|
-
detail: req(str()),
|
|
800
|
-
error: req(str()),
|
|
801
|
-
}),
|
|
802
|
-
render(args, value) {
|
|
803
|
-
if (value.error) return text(value.error)
|
|
804
|
-
return text(`${value.scenario} · ${value.summary}\n\n${value.detail}`)
|
|
805
|
-
},
|
|
806
|
-
},
|
|
807
|
-
async execute({ scenario, topology, timeout, wait }) {
|
|
808
|
-
if (!scenario) throw new Error('scenario is required')
|
|
809
|
-
if (!topology) throw new Error('topology is required: pass the .topo file that is running')
|
|
810
|
-
const report = await runAcceptance({
|
|
811
|
-
id: scenario,
|
|
812
|
-
topology: path.resolve(topology),
|
|
813
|
-
timeoutMs: timeout,
|
|
814
|
-
waitMs: wait,
|
|
815
|
-
})
|
|
816
|
-
const detail = report.results
|
|
817
|
-
.map((r) => {
|
|
818
|
-
const waited = r.pass && r.waitedMs ? ` (converged after ${Math.round(r.waitedMs / 1000)}s)` : ''
|
|
819
|
-
return `${r.pass ? 'PASS' : 'FAIL'} ${r.device} · ${r.id}${waited} — ${r.why}`
|
|
820
|
-
})
|
|
821
|
-
.join('\n')
|
|
822
|
-
return {
|
|
823
|
-
scenario: report.scenario,
|
|
824
|
-
ok: report.ok,
|
|
825
|
-
summary: `${report.passed}/${report.total} checks passed (${report.source})`,
|
|
826
|
-
detail,
|
|
827
|
-
}
|
|
828
|
-
},
|
|
829
|
-
})
|
|
786
|
+
for (const spec of TOOL_SPECS) {
|
|
787
|
+
add(tools, {
|
|
788
|
+
name: spec.name,
|
|
789
|
+
description: spec.description,
|
|
790
|
+
// A private copy per front end: `add()` and the schema helpers walk these
|
|
791
|
+
// nodes, so the two tables must never share one object graph.
|
|
792
|
+
parameters: structuredClone(spec.params),
|
|
793
|
+
output: OUTPUTS[spec.name],
|
|
794
|
+
execute: EXEC[spec.name],
|
|
795
|
+
})
|
|
796
|
+
}
|
|
830
797
|
}
|
|
831
798
|
|
|
832
799
|
/** Model metadata for the plugin row. */
|
|
@@ -835,3 +802,10 @@ export const dsh = {
|
|
|
835
802
|
version: VERSION,
|
|
836
803
|
description: 'Automate Huawei eNSP labs: build topology, boot devices, push config with verification.',
|
|
837
804
|
}
|
|
805
|
+
|
|
806
|
+
// The package root is the plugin entry, because that is what the Harness loads.
|
|
807
|
+
// Library users reached for the same specifier and found none of the functions the
|
|
808
|
+
// README shows, so the core surface is re-exported here as well. Nothing in
|
|
809
|
+
// `core` collides with the plugin exports above, and `dsh-ensp/core` still works
|
|
810
|
+
// for anyone who already imports it.
|
|
811
|
+
export * from '../core/index.mjs'
|