dsh-ensp 0.3.3 → 0.5.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.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Curriculum knowledge attached to scenarios.
3
+ *
4
+ * The scenario presets say what to build and in what order. They deliberately do
5
+ * not carry commands, because the whole point of the tool is to catch a copied
6
+ * command that does not fit. What they were missing is the vocabulary the lab is
7
+ * teaching: which LSA belongs to which area, which attribute wins a BGP decision,
8
+ * which port role only MSTP has. That is exactly the material a certification
9
+ * question bank covers.
10
+ *
11
+ * This module reads a small generated index instead of the question bank itself:
12
+ * one topic, the scenarios that teach it, and a list of short question/answer
13
+ * pairs. Keeping the bank out of the package matters for two reasons. Most of its
14
+ * questions are three-sentence reading exercises that say nothing, and shipping
15
+ * a copyrighted question bank inside a GPL package is not a reasonable default.
16
+ *
17
+ * @module dsh-ensp/knowledge
18
+ */
19
+
20
+ import { readFileSync } from 'node:fs'
21
+
22
+ let cache = null
23
+
24
+ /**
25
+ * Load the generated index, once.
26
+ *
27
+ * Read through `new URL` so the path resolves from this module rather than from
28
+ * whatever directory the caller happens to run in.
29
+ *
30
+ * @returns {object}
31
+ */
32
+ function catalogue() {
33
+ if (cache) return cache
34
+ const text = readFileSync(new URL('./knowledge.json', import.meta.url), 'utf8')
35
+ cache = JSON.parse(text)
36
+ return cache
37
+ }
38
+
39
+ /** @returns {string[]} the topics the index covers. */
40
+ export function knowledgeTopics() {
41
+ return Object.keys(catalogue().topics).sort()
42
+ }
43
+
44
+ /**
45
+ * Knowledge points for one scenario.
46
+ *
47
+ * A topic can feed several scenarios: `ospf` covers six of them, and each gets the
48
+ * same points. That repetition is intentional. A learner running the multi-area
49
+ * lab should not have to know that the material also sits under another scene.
50
+ *
51
+ * @param {string} scenarioId
52
+ * @returns {Array<{topic: string, n: number, q: string, a: string}>}
53
+ */
54
+ export function knowledgeFor(scenarioId) {
55
+ const out = []
56
+ for (const [topic, entry] of Object.entries(catalogue().topics)) {
57
+ if (!entry.scenarios?.includes(scenarioId)) continue
58
+ for (const point of entry.points || []) out.push({ topic, ...point })
59
+ }
60
+ return out
61
+ }
62
+
63
+ /**
64
+ * Search the index by question or answer text.
65
+ *
66
+ * Case-insensitive substring match, same contract as {@link searchScenarios} so the
67
+ * two can be chained in a CLI command without surprising the user.
68
+ *
69
+ * @param {string} query
70
+ * @returns {Array<{topic: string, n: number, q: string, a: string}>}
71
+ */
72
+ export function searchKnowledge(query) {
73
+ const needle = String(query ?? '').trim().toLowerCase()
74
+ if (!needle) return []
75
+ const out = []
76
+ for (const [topic, entry] of Object.entries(catalogue().topics)) {
77
+ for (const point of entry.points || []) {
78
+ if (`${point.q} ${point.a}`.toLowerCase().includes(needle)) out.push({ topic, ...point })
79
+ }
80
+ }
81
+ return out
82
+ }
83
+
84
+ /**
85
+ * Render the knowledge section for a scenario brief.
86
+ *
87
+ * Returns an empty string when the scenario has none, so callers can append it
88
+ * unconditionally.
89
+ *
90
+ * @param {string} scenarioId
91
+ * @param {object} [options]
92
+ * @param {number} [options.limit=10]
93
+ * @returns {string}
94
+ */
95
+ export function renderKnowledge(scenarioId, { limit = 10 } = {}) {
96
+ const points = knowledgeFor(scenarioId).slice(0, limit)
97
+ if (!points.length) return ''
98
+ const lines = ['', '## 知识点(来自认证题库,按主题归类)']
99
+ for (const p of points) {
100
+ lines.push(`- ${p.q}`)
101
+ lines.push(` 答: ${p.a}`)
102
+ }
103
+ return lines.join('\n')
104
+ }
@@ -35,6 +35,7 @@ import path from 'node:path'
35
35
 
36
36
  import { calibrateAnchors, DEFAULT_ANCHORS } from './calibrate.mjs'
37
37
  import { checkTopologySupport, describeBlocked, rootFromExe } from './compat.mjs'
38
+ import { resolveEnspExe } from './paths.mjs'
38
39
  import { readTopology } from './topology.mjs'
39
40
 
40
41
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
@@ -301,7 +302,7 @@ export function enspPids() {
301
302
  * @returns {Promise<{pid: number, hwnd: string}>}
302
303
  */
303
304
  export async function launchEnsp({ topology, exspPath, readyTimeoutMs = 120000 } = {}) {
304
- const exe = exspPath ?? process.env.DSH_ENSP_EXE ?? 'E:\\Huawei\\eNSP\\eNSP_Client.exe'
305
+ const exe = resolveEnspExe(exspPath).path
305
306
  const target = topology ? path.resolve(topology) : null
306
307
 
307
308
  // Reject topologies whose devices have no installed image *before* starting the
@@ -469,7 +470,7 @@ $p = Start-Process -FilePath '${exe.replace(/'/g, "''")}'${topology ? ` -Argumen
469
470
  * @param {string} topology
470
471
  */
471
472
  function reopenTopology(topology) {
472
- const exe = process.env.DSH_ENSP_EXE ?? 'E:\\Huawei\\eNSP\\eNSP_Client.exe'
473
+ const exe = resolveEnspExe().path
473
474
  startDetached(exe, topology)
474
475
  }
475
476
 
@@ -591,15 +592,87 @@ Start-Sleep -Milliseconds 250
591
592
  return r.ok && r.stdout.split(/\s+/).pop() === 'True'
592
593
  }
593
594
 
595
+ /**
596
+ * The window handle that currently holds the foreground, or null.
597
+ *
598
+ * Captured before eNSP is raised so it can be handed back afterwards.
599
+ *
600
+ * @returns {string|null}
601
+ */
602
+ export function foregroundWindow() {
603
+ const r = runPowerShell(
604
+ `$h = [EnspWin]::GetForegroundWindow()
605
+ ([int64]$h).ToString()`,
606
+ { timeoutMs: 15000 },
607
+ )
608
+ const raw = (r.stdout || '').trim().split(/\s+/).pop()
609
+ if (!raw || raw === '0') return null
610
+ return raw
611
+ }
612
+
613
+ /**
614
+ * Put the foreground back on a previously active window.
615
+ *
616
+ * Used after a click that had to raise eNSP: leaving eNSP in front is a
617
+ * side effect the caller did not ask for. The window state is not touched, so a
618
+ * visible topology stays visible.
619
+ *
620
+ * @param {string|null} hwnd
621
+ * @returns {boolean} whether the foreground moved back
622
+ */
623
+ export function restoreForeground(hwnd) {
624
+ if (!hwnd) return false
625
+ const r = runPowerShell(
626
+ `$prev = [IntPtr]::new([int64]${hwnd})
627
+ $ok = [EnspWin]::SetForegroundWindow($prev)
628
+ Start-Sleep -Milliseconds 120
629
+ ($ok -and ([EnspWin]::GetForegroundWindow() -eq $prev)).ToString()`,
630
+ { timeoutMs: 15000 },
631
+ )
632
+ return r.ok && (r.stdout || '').trim().split(/\s+/).pop() === 'True'
633
+ }
634
+
594
635
  /**
595
636
  * Minimise the eNSP window, handing the desktop back to the user.
637
+ *
638
+ * `ShowWindow(SW_MINIMIZE)` minimises without moving the activation: the window
639
+ * is iconic but still holds the foreground, and Windows then treats the first
640
+ * taskbar click as an activation correction, so the window appears not to come
641
+ * back. Restoring the previous foreground window after minimising keeps the
642
+ * taskbar behaviour standard.
643
+ *
596
644
  * @param {string} hwnd
645
+ * @param {{previousForeground?: string|null}} [options]
646
+ * @returns {{iconic: boolean, handedBack: boolean, foreground: string|null}}
597
647
  */
598
- export function minimiseEnsp(hwnd) {
599
- runPowerShell(
600
- `[EnspWin]::ShowWindow([IntPtr]::new([int64]${hwnd}), 6) | Out-Null; 'ok'`,
601
- { timeoutMs: 15000 },
648
+ export function minimiseEnsp(hwnd, options = {}) {
649
+ const previous = options.previousForeground ?? null
650
+ const r = runPowerShell(
651
+ `
652
+ $h = [IntPtr]::new([int64]${hwnd})
653
+ [EnspWin]::ShowWindow($h, 6) | Out-Null
654
+ Start-Sleep -Milliseconds 150
655
+ $fg = [EnspWin]::GetForegroundWindow()
656
+ $iconic = [EnspWin]::IsIconic($h)
657
+ $back = $false
658
+ if ($iconic -and $fg -eq $h) {
659
+ $prev = $null
660
+ ${previous ? `$prev = [IntPtr]::new([int64]${previous})` : ''}
661
+ if ($prev -ne $null -and $prev -ne [IntPtr]::Zero) {
662
+ $back = [EnspWin]::SetForegroundWindow($prev)
663
+ }
664
+ $fg = [EnspWin]::GetForegroundWindow()
665
+ if ($fg -eq $h) { $back = $false }
666
+ }
667
+ "ICONIC=$iconic FOREGROUND=" + ([int64]$fg).ToString() + " BACK=$back"
668
+ `,
669
+ { timeoutMs: 20000 },
602
670
  )
671
+ const text = r.stdout || ''
672
+ const iconic = /ICONIC=True/i.test(text)
673
+ const handedBack = /BACK=True/i.test(text)
674
+ const m = /FOREGROUND=(-?\d+)/.exec(text)
675
+ return { iconic, handedBack, foreground: m ? m[1] : null }
603
676
  }
604
677
 
605
678
  /**
@@ -820,6 +893,8 @@ export async function startAllDevices({
820
893
  // Doing it the other way round reports the window that was on top a moment ago
821
894
  // (usually the terminal that launched us) as if it were a permanent blocker.
822
895
  report({ phase: 'focus', hwnd })
896
+ // Remember who had the foreground so the minimised eNSP does not keep it.
897
+ const previousForeground = minimise ? foregroundWindow() : null
823
898
  let focused = focusEnsp(hwnd)
824
899
  if (focused) await new Promise((resolve) => setTimeout(resolve, 300))
825
900
 
@@ -867,8 +942,8 @@ export async function startAllDevices({
867
942
 
868
943
  // Hand the desktop back immediately; boot continues without the window.
869
944
  if (minimise) {
870
- minimiseEnsp(hwnd)
871
- report({ phase: 'minimised' })
945
+ const handback = minimiseEnsp(hwnd, { previousForeground })
946
+ report({ phase: 'minimised', ...handback })
872
947
  }
873
948
 
874
949
  report({ phase: 'wait', devices: targets.length })
@@ -909,7 +984,12 @@ export function stopAllDevices(hwnd, options = {}) {
909
984
  anchors = { ...DEFAULT_ANCHORS, method: 'default', detail: {} }
910
985
  }
911
986
  }
987
+ const previousForeground = foregroundWindow()
912
988
  const focused = focusEnsp(hwnd)
913
989
  const result = clickToolbar(hwnd, { button: 'stop', anchors })
914
- return { focused, anchors, ...result }
990
+ // The click needs eNSP in front, but leaving it there surprises the user who
991
+ // asked for a stop: hand the foreground back without touching the window
992
+ // state, so the topology stays visible while the devices power down.
993
+ const handback = restoreForeground(previousForeground)
994
+ return { focused, anchors, handback, ...result }
915
995
  }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Locate the eNSP client and VBoxManage.
3
+ *
4
+ * Both used to be hardcoded to `E:\`, which made every machine with a normal
5
+ * install fail at launch. The lookup order is:
6
+ *
7
+ * 1. an explicit override (CLI flag, plugin config, or function argument)
8
+ * 2. `DSH_ENSP_EXE` / `ENSP_EXE` / `DSH_VBOX`
9
+ * 3. the Windows install registry
10
+ * 4. the usual install roots
11
+ * 5. `where` on PATH (VBoxManage only)
12
+ *
13
+ * Every candidate that was tried is reported on failure, so a wrong override
14
+ * produces a message that says what was looked at instead of a bare ENOENT.
15
+ */
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+ import { spawnSync } from 'node:child_process'
19
+
20
+ /** Registry keys an installer may have written the install root to. */
21
+ const ENSP_REGISTRY = [
22
+ ['HKLM\\SOFTWARE\\Huawei\\eNSP', 'InstallPath'],
23
+ ['HKLM\\SOFTWARE\\WOW6432Node\\Huawei\\eNSP', 'InstallPath'],
24
+ ['HKLM\\SOFTWARE\\Huawei\\eNSP', 'InstallDir'],
25
+ ]
26
+
27
+ /** Install roots seen on real machines, most common first. */
28
+ const ENSP_ROOTS = [
29
+ 'C:\\Program Files\\Huawei\\eNSP',
30
+ 'C:\\Program Files (x86)\\Huawei\\eNSP',
31
+ 'D:\\Huawei\\eNSP',
32
+ 'E:\\Huawei\\eNSP',
33
+ ]
34
+
35
+ /** Where VirtualBox installs itself, most common first. */
36
+ const VBOX_PATHS = [
37
+ 'C:\\Program Files\\Oracle\\VirtualBox\\VBoxManage.exe',
38
+ 'C:\\Program Files (x86)\\Oracle\\VirtualBox\\VBoxManage.exe',
39
+ 'D:\\VirtualBox\\VBoxManage.exe',
40
+ 'E:\\VirtualBox\\VBoxManage.exe',
41
+ ]
42
+
43
+ const isWindows = process.platform === 'win32'
44
+
45
+ /** True when the path exists and is a file. Never throws. */
46
+ function isFile(candidate) {
47
+ if (!candidate) return false
48
+ try {
49
+ return fs.statSync(candidate).isFile()
50
+ } catch {
51
+ return false
52
+ }
53
+ }
54
+
55
+ /** Read one string value out of the registry, or null. */
56
+ function registryValue(key, name) {
57
+ if (!isWindows) return null
58
+ 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
62
+ }
63
+
64
+ /** Expand `%VAR%` and strip surrounding quotes from a registry value. */
65
+ function cleanRoot(value) {
66
+ if (!value) return null
67
+ const unquoted = value.trim().replace(/^"|"$/g, '')
68
+ const expanded = unexpanded(unquoted)
69
+ return expanded || null
70
+ }
71
+
72
+ /** Expand Windows environment variables without depending on the shell. */
73
+ function unexpanded(value) {
74
+ return value.replace(/%([^%]+)%/g, (whole, name) => process.env[name] ?? whole)
75
+ }
76
+
77
+ /** Every candidate eNSP client path, in lookup order, with its source. */
78
+ export function enspCandidates(override) {
79
+ const out = []
80
+ if (override) out.push({ path: override, source: 'override' })
81
+ for (const name of ['DSH_ENSP_EXE', 'ENSP_EXE']) {
82
+ const v = process.env[name]
83
+ if (v) out.push({ path: v, source: name })
84
+ }
85
+ 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}` })
88
+ }
89
+ for (const root of ENSP_ROOTS) out.push({ path: path.join(root, 'eNSP_Client.exe'), source: 'install root' })
90
+ return out
91
+ }
92
+
93
+ /** Every candidate VBoxManage path, in lookup order, with its source. */
94
+ export function vboxCandidates(override) {
95
+ const out = []
96
+ if (override) out.push({ path: override, source: 'override' })
97
+ if (process.env.DSH_VBOX) out.push({ path: process.env.DSH_VBOX, source: 'DSH_VBOX' })
98
+ 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]
101
+ if (first) out.push({ path: first, source: 'PATH' })
102
+ }
103
+ for (const p of VBOX_PATHS) out.push({ path: p, source: 'install root' })
104
+ return out
105
+ }
106
+
107
+ /** First existing candidate, or null. */
108
+ function firstExisting(candidates) {
109
+ for (const c of candidates) if (isFile(c.path)) return c
110
+ return null
111
+ }
112
+
113
+ /**
114
+ * Resolve the eNSP client executable.
115
+ *
116
+ * @param {string} [override] - explicit path from a flag or plugin config
117
+ * @returns {{path: string, source: string, tried: string[]}}
118
+ * @throws when nothing exists
119
+ */
120
+ export function resolveEnspExe(override) {
121
+ // An explicit path is a statement of intent, not a hint. Falling back to some
122
+ // other installation would run a different eNSP than the one that was asked
123
+ // for, and the user would see a working launch on the machine they pointed at
124
+ // and a mystery on the machine they meant.
125
+ if (override && !isFile(override)) {
126
+ throw new Error(
127
+ `the eNSP client path given does not exist: ${override}\n`
128
+ + 'Check the path, or drop --exe to let the tool search for an installation.',
129
+ )
130
+ }
131
+ const candidates = enspCandidates(override)
132
+ const hit = firstExisting(candidates)
133
+ if (hit) return { path: hit.path, source: hit.source, tried: candidates.map((c) => c.path) }
134
+ const tried = candidates.map((c) => ` ${c.path} (${c.source})`).join('\n')
135
+ throw new Error(
136
+ `eNSP client not found. Looked at:\n${tried}\n\n`
137
+ + 'Pass --exe, set DSH_ENSP_EXE, or configure the plugin, with the full path to eNSP_Client.exe. '
138
+ + 'Installing eNSP to its default location also works.',
139
+ )
140
+ }
141
+
142
+ /**
143
+ * Resolve VBoxManage.
144
+ *
145
+ * @param {string} [override]
146
+ * @returns {{path: string, source: string, tried: string[]}}
147
+ * @throws when nothing exists
148
+ */
149
+ export function resolveVBoxManage(override) {
150
+ if (override && !isFile(override)) {
151
+ throw new Error(
152
+ `the VBoxManage path given does not exist: ${override}\n`
153
+ + 'Check the path, or drop --vbox to let the tool search for an installation.',
154
+ )
155
+ }
156
+ const candidates = vboxCandidates(override)
157
+ const hit = firstExisting(candidates)
158
+ if (hit) return { path: hit.path, source: hit.source, tried: candidates.map((c) => c.path) }
159
+ const tried = candidates.map((c) => ` ${c.path} (${c.source})`).join('\n')
160
+ throw new Error(
161
+ `VBoxManage.exe not found. Looked at:\n${tried}\n\n`
162
+ + 'Pass --vbox, set DSH_VBOX, or add the VirtualBox install directory to PATH.',
163
+ )
164
+ }
165
+
166
+ /**
167
+ * Resolve the eNSP install root, which holds `res/items.xml` and the plugins.
168
+ *
169
+ * Derived from the client path when it can be found, so a machine that launches
170
+ * successfully also has a correct catalogue path.
171
+ *
172
+ * @param {{exe?: string, root?: string}} [options]
173
+ * @returns {string|null}
174
+ */
175
+ export function resolveEnspRoot(options = {}) {
176
+ if (options.root && fs.existsSync(options.root)) return options.root
177
+ if (options.exe) return path.dirname(options.exe)
178
+ const hit = firstExisting(enspCandidates())
179
+ return hit ? path.dirname(hit.path) : null
180
+ }
181
+
182
+ /** Non-throwing probe used by `doctor`, which reports instead of raising. */
183
+ export function probePaths({ exe, vbox } = {}) {
184
+ const ensp = firstExisting(enspCandidates(exe))
185
+ const box = firstExisting(vboxCandidates(vbox))
186
+ return {
187
+ ensp: ensp ? { ok: true, path: ensp.path, source: ensp.source } : { ok: false, path: null },
188
+ vbox: box ? { ok: true, path: box.path, source: box.source } : { ok: false, path: null },
189
+ }
190
+ }
@@ -22,10 +22,37 @@
22
22
  * @module dsh-ensp/provision
23
23
  */
24
24
 
25
- import { openConsole, viewOf } from './console.mjs'
25
+ import { openConsole } from './console.mjs'
26
26
 
27
27
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
28
28
 
29
+ /**
30
+ * Errors that mean "the device is already in the state you asked for".
31
+ *
32
+ * Re-running the same configuration is the normal way to work: change one line,
33
+ * apply again, look at the effect. VRP answers the unchanged lines with an error,
34
+ * and aborting the batch there makes the tool unusable for that loop. These
35
+ * specific replies are recorded as no-ops instead. Everything else still stops
36
+ * the batch, because a genuine rejection means the rest of the script is
37
+ * probably landing on the wrong object.
38
+ */
39
+ const IDEMPOTENT_RE =
40
+ /already exists|The VLAN already exists|address already exists|The object already exists|has already been created|is already configured|The route already exists|The ACL rule already exists/i
41
+
42
+ /**
43
+ * Whether a VRP error means "this line is already in effect".
44
+ *
45
+ * Exported so the behaviour can be tested without a device: the distinction
46
+ * between "already there" and "rejected" is the whole point of a re-runnable
47
+ * apply, and it is easy to widen the pattern until real failures are swallowed.
48
+ *
49
+ * @param {string} message
50
+ * @returns {boolean}
51
+ */
52
+ export function isIdempotentError(message) {
53
+ return IDEMPOTENT_RE.test(String(message ?? ''))
54
+ }
55
+
29
56
  /**
30
57
  * Split a configuration script into executable lines.
31
58
  *
@@ -111,21 +138,21 @@ export async function pushConfig({
111
138
  // not. Because we already entered system view above, a leading `system-view`
112
139
  // would be rejected ("Unrecognized command") and abort the batch. Treat it as
113
140
  // idempotent: skip it when we are already in the right place.
114
- if (/^system-view$/i.test(command) && viewOf(session.prompt) === 'system') {
141
+ if (/^system-view$/i.test(command) && session.view === 'system') {
115
142
  applied += 1
116
143
  transcript.push({ line, command, ok: true, skipped: 'already in system view', output: '' })
117
144
  report?.({ phase: 'command', device, line, command, ok: true, skipped: true })
118
145
  continue
119
146
  }
120
147
  // Likewise `return` from system view is a no-op noise source.
121
- if (/^return$/i.test(command) && viewOf(session.prompt) === 'system') {
148
+ if (/^return$/i.test(command) && session.view === 'system') {
122
149
  applied += 1
123
150
  transcript.push({ line, command, ok: true, skipped: 'already at top of system view', output: '' })
124
151
  report?.({ phase: 'command', device, line, command, ok: true, skipped: true })
125
152
  continue
126
153
  }
127
154
  // Guard against view drift: a previous `quit` may have dropped us out.
128
- if (viewOf(session.prompt) === 'user') {
155
+ if (session.view === 'user') {
129
156
  const back = await session.ensureSystemView()
130
157
  if (!back.ok) {
131
158
  return {
@@ -159,6 +186,15 @@ export async function pushConfig({
159
186
  })
160
187
  report({ phase: 'command', device, line, command, ok: result.ok, view: result.view })
161
188
 
189
+ if (!result.ok && isIdempotentError(result.error)) {
190
+ // Already in the requested state. Count it so the totals stay meaningful,
191
+ // and keep the transcript honest about what the device actually said.
192
+ applied += 1
193
+ transcript[transcript.length - 1].skipped = 'already in the requested state'
194
+ report({ phase: 'command', device, line, command, ok: true, skipped: true, view: result.view })
195
+ continue
196
+ }
197
+
162
198
  if (!result.ok) {
163
199
  if (stopOnError) {
164
200
  return {
package/src/dsh/index.mjs CHANGED
@@ -48,6 +48,7 @@ import { pushConfig, pushConfigMany, queryDevice } from '../core/provision.mjs'
48
48
  import { SCENARIOS, SCENARIO_IDS, renderScenario, searchScenarios, byLevel } from '../core/presets.mjs'
49
49
  import { doctor, modelsOf } from '../core/doctor.mjs'
50
50
  import { runAcceptance, hasChecks } from '../core/acceptance.mjs'
51
+ import { knowledgeFor, searchKnowledge, knowledgeTopics, renderKnowledge } from '../core/knowledge.mjs'
51
52
  import { VERSION } from '../core/version.mjs'
52
53
 
53
54
  /** Declared hard dependency: the tool registry. */
@@ -104,6 +105,45 @@ function stripRequired(node) {
104
105
  /** Declare a closed output object. */
105
106
  const obj = (properties) => compileObject(properties)
106
107
 
108
+ /**
109
+ * Fill in required properties that a return branch omitted.
110
+ *
111
+ * A tool has several branches, and some of them legitimately have nothing to
112
+ * report: when eNSP is not running there is no pid, no hwnd and no anchor. The
113
+ * consumer still benefits from a stable shape, so the branch does not have to
114
+ * repeat empty fields and the schema does not have to relax.
115
+ *
116
+ * Missing required keys get the empty value for their type. Keys the branch did
117
+ * return are never touched, so a wrong value still reaches the validator.
118
+ */
119
+ /**
120
+ * Fill in the branches a tool did not return.
121
+ *
122
+ * Exported so the contract can be tested without calling a device: every tool
123
+ * declares its full output shape, and a run that legitimately has nothing to say
124
+ * about one field must still satisfy that shape.
125
+ */
126
+ export function conform(schema, value) {
127
+ if (!schema || schema.type !== 'object') return value
128
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value
129
+ const out = { ...value }
130
+ for (const key of schema.required ?? []) {
131
+ if (out[key] !== undefined) continue
132
+ out[key] = emptyFor(schema.properties?.[key])
133
+ }
134
+ return out
135
+ }
136
+
137
+ /** The empty value for a schema node's type. */
138
+ function emptyFor(node) {
139
+ const type = node?.type
140
+ if (type === 'number' || type === 'integer') return 0
141
+ if (type === 'boolean') return false
142
+ if (type === 'array') return []
143
+ if (type === 'object') return {}
144
+ return ''
145
+ }
146
+
107
147
  /** One plain text content block. */
108
148
  const text = (value) => [{ type: 'text', text: String(value) }]
109
149
 
@@ -121,7 +161,8 @@ function add(tools, definition) {
121
161
  return tools.register({
122
162
  ...rest,
123
163
  async execute(args) {
124
- return execute(args ?? {})
164
+ const value = await execute(args ?? {})
165
+ return conform(definition.output?.schema, value)
125
166
  },
126
167
  })
127
168
  }
@@ -558,13 +599,55 @@ export function apply(ctx) {
558
599
  brief +
559
600
  (hasChecks(id)
560
601
  ? `\n\n验收: ensp_verify { scenario: "${id}", topology: "${id}.topo" }`
561
- : '\n\n验收: 无专门断言,ensp_verify 会逐条跑场景里的验证命令'),
602
+ : '\n\n验收: 无专门断言,ensp_verify 会逐条跑场景里的验证命令') +
603
+ renderKnowledge(id),
562
604
  }
563
605
  },
564
606
  })
565
607
 
566
608
  /* ---------------------------------------------------------- preset config */
567
609
 
610
+ add(tools, {
611
+ name: 'ensp_knowledge',
612
+ description:
613
+ 'Look up curriculum knowledge derived from a certification question bank: short ' +
614
+ 'question/answer pairs filed by topic, and the scenarios that teach each topic. ' +
615
+ 'Pass scenario to list what one lab teaches, or query to search the index.',
616
+ parameters: {
617
+ query: str('Keyword, e.g. "LSA" or "MED".'),
618
+ scenario: str('Scenario id, e.g. "ospf-lsa-types".'),
619
+ },
620
+ output: {
621
+ schema: obj({
622
+ scope: req(str()),
623
+ count: req(num()),
624
+ points: req(str()),
625
+ error: req(str()),
626
+ }),
627
+ render(args, value) {
628
+ return text(value.points || '(no knowledge point matched)')
629
+ },
630
+ },
631
+ async execute({ query, scenario }) {
632
+ if (scenario) {
633
+ if (!SCENARIOS[scenario]) throw new Error(`unknown scenario "${scenario}"`)
634
+ const points = knowledgeFor(scenario)
635
+ return {
636
+ scope: scenario,
637
+ count: points.length,
638
+ points: points.map((p) => `#${p.n} [${p.topic}] ${p.q}\n 答: ${p.a}`).join('\n'),
639
+ }
640
+ }
641
+ if (!query) throw new Error('pass query or scenario')
642
+ const points = searchKnowledge(query)
643
+ return {
644
+ scope: String(query),
645
+ count: points.length,
646
+ points: points.map((p) => `#${p.n} [${p.topic}] ${p.q}\n 答: ${p.a}`).join('\n'),
647
+ }
648
+ },
649
+ })
650
+
568
651
  add(tools, {
569
652
  name: 'ensp_preset_config',
570
653
  description: 'Return the configuration text for the built-in two-PC lab.',
@@ -40,6 +40,7 @@ import {
40
40
  import { calibrateAnchors, inspectAnchors, DEFAULT_ANCHORS } from '../core/calibrate.mjs'
41
41
  import { doctor, modelsOf } from '../core/doctor.mjs'
42
42
  import { runAcceptance, hasChecks, EXPECTATIONS } from '../core/acceptance.mjs'
43
+ import { knowledgeFor, searchKnowledge, knowledgeTopics } from '../core/knowledge.mjs'
43
44
  import { pushConfig, pushConfigMany, queryDevice } from '../core/provision.mjs'
44
45
  import { SCENARIOS, SCENARIO_IDS, byLevel, renderScenario, searchScenarios } from '../core/presets.mjs'
45
46
  import { PACKAGE_NAME, VERSION } from '../core/version.mjs'
@@ -331,6 +332,7 @@ const TOOLS = [
331
332
  hasTopology: hasLab(id),
332
333
  hasCuratedChecks: hasChecks(id),
333
334
  ...(hasLab(id) ? { devices: labDevices(id), addressing: labAddressing(id) } : {}),
335
+ knowledge: knowledgeFor(id),
334
336
  }
335
337
  },
336
338
  },
@@ -373,6 +375,27 @@ const TOOLS = [
373
375
  return runAcceptance({ id: scenario, topology, timeoutMs: timeout, waitMs: wait })
374
376
  },
375
377
  },
378
+ {
379
+ name: 'ensp_knowledge',
380
+ description:
381
+ 'Search the curriculum knowledge index derived from a certification question bank. ' +
382
+ 'Use it to look up the vocabulary a scenario teaches (which LSA belongs to which area, ' +
383
+ 'which BGP attribute wins, which port role is MSTP only) without leaving the lab. ' +
384
+ 'Returns short question/answer pairs plus the scenarios that teach each topic.',
385
+ inputSchema: obj({
386
+ query: { type: 'string', description: 'keyword, e.g. "LSA" or "MED"' },
387
+ scenario: { type: 'string', description: 'scenario id, to list what that lab teaches' },
388
+ }),
389
+ async run({ query, scenario }) {
390
+ if (scenario) {
391
+ if (!SCENARIOS[scenario]) throw new Error(`unknown scenario "${scenario}"`)
392
+ return { scenario, topics: knowledgeTopics(), points: knowledgeFor(scenario) }
393
+ }
394
+ if (!query) throw new Error('pass query or scenario')
395
+ return { query, points: searchKnowledge(query) }
396
+ },
397
+ },
398
+
376
399
  {
377
400
  name: 'ensp_preset_config',
378
401
  description: 'Return the configuration that makes the built-in two-PC lab pass traffic.',