dsh-ensp 0.1.9 → 0.2.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,350 @@
1
+ /**
2
+ * Environment self-check.
3
+ *
4
+ * Every failure this project has hit in the field was an environment problem
5
+ * masquerading as a code problem: a missing device image made eNSP quit after
6
+ * rendering, a window covered by another application swallowed every click, and
7
+ * a non-interactive session accepted `SendInput` while never delivering it. All
8
+ * three look identical from the outside — "nothing happened".
9
+ *
10
+ * `doctor()` turns those into explicit, checkable facts before a lab starts, and
11
+ * every failing check carries the fix in the same object. It never starts eNSP.
12
+ *
13
+ * @module dsh-ensp/doctor
14
+ */
15
+
16
+ import { spawnSync } from 'node:child_process'
17
+ import fs from 'node:fs'
18
+ import os from 'node:os'
19
+ import path from 'node:path'
20
+
21
+ import { checkTopologySupport, probeDevice } from './compat.mjs'
22
+ import { DEFAULT_ANCHORS, calibrateAnchors } from './calibrate.mjs'
23
+ import { enspPid, locateWindow } from './launcher.mjs'
24
+
25
+ export const DEFAULT_ENSP_EXE = 'E:\\Huawei\\eNSP\\eNSP_Client.exe'
26
+ export const DEFAULT_VBOX = 'E:\\VirtualBox\\VBoxManage.exe'
27
+
28
+ const MIN_FREE_MEMORY_MB = 1024
29
+
30
+ /** PowerShell helper that answers "does synthetic input reach this session?". */
31
+ const INPUT_PROBE = String.raw`
32
+ $ErrorActionPreference = 'Stop'
33
+ Add-Type -TypeDefinition @'
34
+ using System;
35
+ using System.Runtime.InteropServices;
36
+ public class EnspInputProbe {
37
+ [StructLayout(LayoutKind.Sequential)] public struct LASTINPUTINFO { public uint cbSize; public uint dwTime; }
38
+ [StructLayout(LayoutKind.Sequential)] public struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }
39
+ [StructLayout(LayoutKind.Sequential)] public struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }
40
+ [StructLayout(LayoutKind.Explicit)] public struct INPUTUNION { [FieldOffset(0)] public MOUSEINPUT mi; [FieldOffset(0)] public KEYBDINPUT ki; }
41
+ [StructLayout(LayoutKind.Sequential)] public struct INPUT { public uint type; public INPUTUNION u; }
42
+ [DllImport("user32.dll")] public static extern bool GetLastInputInfo(ref LASTINPUTINFO p);
43
+ [DllImport("user32.dll", SetLastError=true)] public static extern uint SendInput(uint n, INPUT[] inputs, int cb);
44
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
45
+ [DllImport("kernel32.dll")] public static extern uint WTSGetActiveConsoleSessionId();
46
+ [DllImport("kernel32.dll")] public static extern bool ProcessIdToSessionId(uint pid, out uint sid);
47
+
48
+ public static uint Tick() {
49
+ var l = new LASTINPUTINFO(); l.cbSize = 8;
50
+ if (!GetLastInputInfo(ref l)) return 0;
51
+ return l.dwTime;
52
+ }
53
+
54
+ public static string Run() {
55
+ uint before = Tick();
56
+ var moves = new INPUT[2];
57
+ moves[0].type = 0; moves[0].u.mi.dx = 1; moves[0].u.mi.dy = 0; moves[0].u.mi.dwFlags = 0x0001;
58
+ moves[1].type = 0; moves[1].u.mi.dx = -1; moves[1].u.mi.dy = 0; moves[1].u.mi.dwFlags = 0x0001;
59
+ uint sent = SendInput(2, moves, Marshal.SizeOf(typeof(INPUT)));
60
+ int err = Marshal.GetLastWin32Error();
61
+ System.Threading.Thread.Sleep(120);
62
+ uint after = Tick();
63
+ uint myPid = (uint)System.Diagnostics.Process.GetCurrentProcess().Id;
64
+ uint mySession;
65
+ ProcessIdToSessionId(myPid, out mySession);
66
+ return string.Format("sent={0};err={1};tickBefore={2};tickAfter={3};changed={4};session={5};consoleSession={6};foreground={7}",
67
+ sent, err, before, after, (before != 0 && before != after) ? "true" : "false",
68
+ mySession, WTSGetActiveConsoleSessionId(), GetForegroundWindow());
69
+ }
70
+ }
71
+ '@
72
+ [EnspInputProbe]::Run()
73
+ `
74
+
75
+ /**
76
+ * Can this process deliver synthetic mouse/keyboard input to the session?
77
+ *
78
+ * The probe sends two opposing 1-pixel mouse moves (net cursor movement zero)
79
+ * and compares the session's last-input tick before and after. `SendInput`
80
+ * returning a non-zero count only means the API accepted the call; the tick is
81
+ * what proves it reached the input queue.
82
+ */
83
+ export function probeInputInjection({ timeoutMs = 30000 } = {}) {
84
+ const r = spawnSync('powershell', ['-NoProfile', '-Command', INPUT_PROBE], {
85
+ encoding: 'utf8',
86
+ windowsHide: true,
87
+ timeout: timeoutMs,
88
+ })
89
+ if (r.error) {
90
+ return { ok: false, reason: 'probe-failed', detail: String(r.error.message || r.error) }
91
+ }
92
+ if (r.status !== 0) {
93
+ return { ok: false, reason: 'probe-failed', detail: (r.stderr || r.stdout || '').trim().slice(0, 400) }
94
+ }
95
+ const line = (r.stdout || '').split('\n').map((s) => s.trim()).filter(Boolean).pop() || ''
96
+ const field = (k) => {
97
+ const m = line.match(new RegExp(k + '=([^;]*)'))
98
+ return m ? m[1] : ''
99
+ }
100
+ const changed = field('changed') === 'true'
101
+ return {
102
+ ok: changed,
103
+ sent: Number(field('sent') || 0),
104
+ tickChanged: changed,
105
+ session: field('session'),
106
+ consoleSession: field('consoleSession'),
107
+ foreground: field('foreground'),
108
+ raw: line,
109
+ }
110
+ }
111
+
112
+ /** Free physical memory in MB, or null when it cannot be read. */
113
+ export function freeMemoryMb() {
114
+ const r = spawnSync(
115
+ 'powershell',
116
+ [
117
+ '-NoProfile',
118
+ '-Command',
119
+ "Get-CimInstance Win32_OperatingSystem | ForEach-Object { [int]($_.FreePhysicalMemory / 1024) }",
120
+ ],
121
+ { encoding: 'utf8', windowsHide: true, timeout: 20000 },
122
+ )
123
+ if (r.status !== 0) return null
124
+ const n = Number((r.stdout || '').trim())
125
+ return Number.isFinite(n) && n > 0 ? n : null
126
+ }
127
+
128
+ /** Ports in eNSP's console range that are already listening. */
129
+ export function busyConsolePorts() {
130
+ const r = spawnSync(
131
+ 'powershell',
132
+ [
133
+ '-NoProfile',
134
+ '-Command',
135
+ "Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | " +
136
+ 'Where-Object { $_.LocalPort -ge 2000 -and $_.LocalPort -le 2100 } | ' +
137
+ 'ForEach-Object { $_.LocalPort } | Sort-Object -Unique',
138
+ ],
139
+ { encoding: 'utf8', windowsHide: true, timeout: 20000 },
140
+ )
141
+ if (r.status !== 0) return []
142
+ return (r.stdout || '')
143
+ .split(/\s+/)
144
+ .map(Number)
145
+ .filter((n) => Number.isInteger(n) && n >= 2000 && n <= 2100)
146
+ }
147
+
148
+ function check(id, status, detail, fix) {
149
+ return fix ? { id, status, detail, fix } : { id, status, detail }
150
+ }
151
+
152
+ /**
153
+ * Run every environment check.
154
+ *
155
+ * @param {object} [options]
156
+ * @param {string} [options.exe] eNSP client path
157
+ * @param {string} [options.vbox] VBoxManage path
158
+ * @param {string} [options.root] eNSP installation root (defaults to exe dir)
159
+ * @param {string[]} [options.models] device models the run needs
160
+ * @param {boolean} [options.deep] also run the input probe (slow: ~2 s)
161
+ */
162
+ export function doctor(options = {}) {
163
+ const exe = options.exe || DEFAULT_ENSP_EXE
164
+ const vbox = options.vbox || DEFAULT_VBOX
165
+ const root = options.root || path.dirname(exe)
166
+ const deep = options.deep !== false
167
+ const checks = []
168
+
169
+ // 1. eNSP client present
170
+ const exeOk = fs.existsSync(exe)
171
+ checks.push(
172
+ check(
173
+ 'ensp-installed',
174
+ exeOk ? 'pass' : 'fail',
175
+ exeOk ? exe : `not found: ${exe}`,
176
+ exeOk
177
+ ? undefined
178
+ : 'Install eNSP, or pass --exe with the real path to eNSP_Client.exe.',
179
+ ),
180
+ )
181
+
182
+ // 2. VirtualBox present (eNSP cannot boot a single device without it)
183
+ const vboxOk = fs.existsSync(vbox)
184
+ checks.push(
185
+ check(
186
+ 'virtualbox',
187
+ vboxOk ? 'pass' : 'fail',
188
+ vboxOk ? vbox : `not found: ${vbox}`,
189
+ vboxOk ? undefined : 'Install VirtualBox 5.2.x, which ships with eNSP.',
190
+ ),
191
+ )
192
+
193
+ // 3. Device images for the models this scenario needs
194
+ if (options.models && options.models.length) {
195
+ const missing = []
196
+ for (const model of [...new Set(options.models)]) {
197
+ const p = probeDevice(model, { root })
198
+ if (!p.available) missing.push(`${model}: ${p.reason}`)
199
+ }
200
+ checks.push(
201
+ check(
202
+ 'device-images',
203
+ missing.length === 0 ? 'pass' : 'fail',
204
+ missing.length === 0 ? `${[...new Set(options.models)].join(', ')} all present` : missing.join('; '),
205
+ missing.length === 0
206
+ ? undefined
207
+ : 'Reinstall eNSP with the missing device component selected, or remove those devices from the topology.',
208
+ ),
209
+ )
210
+ }
211
+
212
+ // 4. Console port range has room
213
+ const busy = busyConsolePorts()
214
+ checks.push(
215
+ check(
216
+ 'console-ports',
217
+ busy.length === 0 ? 'pass' : 'warn',
218
+ busy.length === 0 ? '2000-2100 free' : `already listening: ${busy.join(', ')}`,
219
+ busy.length === 0
220
+ ? undefined
221
+ : 'Another eNSP instance is holding these ports. Stop it first, or the new topology will bind elsewhere and consoles will time out.',
222
+ ),
223
+ )
224
+
225
+ // 5. Synthetic input reaches the session (the decisive check)
226
+ if (deep) {
227
+ const input = probeInputInjection()
228
+ checks.push(
229
+ check(
230
+ 'input-injection',
231
+ input.ok ? 'pass' : 'fail',
232
+ input.ok
233
+ ? 'synthetic mouse input reached the input queue'
234
+ : input.reason === 'probe-failed'
235
+ ? `probe failed: ${input.detail}`
236
+ : `SendInput accepted ${input.sent} event(s) but the session last-input tick did not change (session ${input.session}, console session ${input.consoleSession})`,
237
+ input.ok
238
+ ? undefined
239
+ : 'This session cannot deliver mouse input, so the eNSP toolbar button can never be pressed. Run the tool from an interactive desktop session (a normal logged-in terminal), not from a service, a scheduled task, an SSH shell, or an agent sandbox.',
240
+ ),
241
+ )
242
+ }
243
+
244
+ // 6. Memory headroom: each AR_Base needs 256 MB, WLAN images 530-650 MB
245
+ const free = freeMemoryMb()
246
+ if (free === null) {
247
+ checks.push(check('memory', 'warn', 'could not read free physical memory'))
248
+ } else {
249
+ const okMem = free >= MIN_FREE_MEMORY_MB
250
+ checks.push(
251
+ check(
252
+ 'memory',
253
+ okMem ? 'pass' : 'warn',
254
+ `${free} MB free (each router needs ~256 MB)`,
255
+ okMem ? undefined : 'Close some applications; devices may boot very slowly or fail to start.',
256
+ ),
257
+ )
258
+ }
259
+
260
+ // 7. Calibration anchors. The static defaults are only a fallback for a window we
261
+ // could not photograph, so comparing them to each other proves nothing. When eNSP
262
+ // is open, measure the live window instead and say which method produced it.
263
+ const hwnd = options.hwnd ?? (options.live === false ? null : safeWindow())
264
+ if (hwnd) {
265
+ try {
266
+ const anchors = calibrateAnchors(hwnd, { refresh: true })
267
+ const measured = anchors.method === 'measured'
268
+ checks.push(
269
+ check(
270
+ 'anchors',
271
+ measured ? 'pass' : 'warn',
272
+ `${anchors.method}: start (${anchors.start.x},${anchors.start.y}) stop (${anchors.stop.x},${anchors.stop.y})`,
273
+ measured
274
+ ? undefined
275
+ : 'The toolbar could not be located by colour, so the cached fallback is in use. ' +
276
+ 'Bring eNSP to the front and run `ensp probe`.',
277
+ ),
278
+ )
279
+ } catch (error) {
280
+ checks.push(check('anchors', 'warn', `not measured: ${error.message}`, 'Run `ensp probe` with eNSP visible.'))
281
+ }
282
+ } else {
283
+ const a = DEFAULT_ANCHORS.start
284
+ const b = DEFAULT_ANCHORS.stop
285
+ checks.push(
286
+ check(
287
+ 'anchors',
288
+ b.x > a.x ? 'pass' : 'fail',
289
+ `fallback start (${a.x},${a.y}) stop (${b.x},${b.y}); eNSP not open, nothing measured`,
290
+ b.x > a.x ? undefined : 'Fallback anchors are inconsistent; run `ensp probe`.',
291
+ ),
292
+ )
293
+ }
294
+
295
+ const failed = checks.filter((c) => c.status === 'fail')
296
+ const warned = checks.filter((c) => c.status === 'warn')
297
+ return {
298
+ ok: failed.length === 0,
299
+ checks,
300
+ failed: failed.map((c) => c.id),
301
+ warned: warned.map((c) => c.id),
302
+ }
303
+ }
304
+
305
+ /**
306
+ * The eNSP window, if one is open. Best effort: doctor must still work when eNSP is
307
+ * not installed, not running, or running as a zombie without threads.
308
+ */
309
+ function safeWindow() {
310
+ try {
311
+ return locateWindow(enspPid()) ?? null
312
+ } catch {
313
+ return null
314
+ }
315
+ }
316
+
317
+ /** Render a doctor report for a terminal. */
318
+ export function formatDoctor(report) {
319
+ const mark = { pass: 'ok ', warn: 'warn', fail: 'FAIL' }
320
+ const lines = []
321
+ for (const c of report.checks) {
322
+ lines.push(`[${mark[c.status] || c.status}] ${c.id}: ${c.detail}`)
323
+ if (c.fix) lines.push(` fix: ${c.fix}`)
324
+ }
325
+ lines.push('')
326
+ lines.push(
327
+ report.ok
328
+ ? `environment ok${report.warned.length ? ` (${report.warned.length} warning(s))` : ''}`
329
+ : `environment is not ready: ${report.failed.join(', ')}`,
330
+ )
331
+ return lines.join('\n')
332
+ }
333
+
334
+ /** Convenience wrapper used by the CLI, MCP server and plugin. */
335
+ export function checkEnvironment(options = {}) {
336
+ const report = doctor(options)
337
+ return { ...report, text: formatDoctor(report) }
338
+ }
339
+
340
+ /**
341
+ * Models a topology needs, so `doctor()` can check the right images.
342
+ * Accepts a parsed topology (from `readTopology`).
343
+ */
344
+ export function modelsOf(topology) {
345
+ const devices = topology?.devices ?? []
346
+ return [...new Set(devices.map((d) => d.model))].sort()
347
+ }
348
+
349
+ /** Free-memory constant, exported so tests can assert against it. */
350
+ export { MIN_FREE_MEMORY_MB }
@@ -59,6 +59,29 @@ export {
59
59
  rootFromExe,
60
60
  } from './compat.mjs'
61
61
 
62
+ export {
63
+ doctor,
64
+ formatDoctor,
65
+ checkEnvironment,
66
+ probeInputInjection,
67
+ freeMemoryMb,
68
+ busyConsolePorts,
69
+ modelsOf,
70
+ DEFAULT_ENSP_EXE,
71
+ DEFAULT_VBOX,
72
+ MIN_FREE_MEMORY_MB,
73
+ } from './doctor.mjs'
74
+
75
+ export {
76
+ EXPECTATIONS,
77
+ evaluateAssertion,
78
+ planFor,
79
+ hasChecks,
80
+ consolePorts,
81
+ runAcceptance,
82
+ formatAcceptance,
83
+ } from './acceptance.mjs'
84
+
62
85
  export { ConsoleSession, openConsole, probeConsole, viewOf, hostnameOf } from './console.mjs'
63
86
 
64
87
  export {
@@ -100,6 +100,7 @@ public class EnspWin {
100
100
  [DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr h, out RECT r);
101
101
  [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h);
102
102
  [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
103
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
103
104
  [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);
104
105
  [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
105
106
  [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);
@@ -111,6 +112,8 @@ public class EnspWin {
111
112
  [DllImport("user32.dll")] public static extern IntPtr WindowFromPoint(POINT p);
112
113
  [DllImport("user32.dll")] public static extern IntPtr GetAncestor(IntPtr h, uint flags);
113
114
 
115
+ [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h, uint msg, IntPtr wp, IntPtr lp);
116
+
114
117
  [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }
115
118
  [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X, Y; }
116
119
 
@@ -162,13 +165,31 @@ public class EnspWin {
162
165
  return found != IntPtr.Zero ? found : fallback;
163
166
  }
164
167
 
168
+ private const uint WM_SYSCOMMAND = 0x0112;
169
+ private const int SC_RESTORE = 0xF120;
170
+
171
+ /**
172
+ * Bring a minimised window back to its normal size.
173
+ *
174
+ * ShowWindow(h, SW_RESTORE) reports failure on this MFC frame and leaves the
175
+ * window at its icon size (measured: 237x39 at -32000,-32000), so every later
176
+ * screenshot is taken on the icon and calibration finds no toolbar. The
177
+ * WM_SYSCOMMAND/SC_RESTORE path is what the system menu itself uses and it does
178
+ * restore the frame to 1280x800.
179
+ */
180
+ public static void RestoreWindow(IntPtr h) {
181
+ if (!IsIconic(h)) return;
182
+ ShowWindow(h, 9); // best effort, may report false
183
+ SendMessage(h, WM_SYSCOMMAND, (IntPtr)SC_RESTORE, IntPtr.Zero);
184
+ }
185
+
165
186
  public static bool FocusWindow(IntPtr h) {
187
+ RestoreWindow(h);
166
188
  IntPtr fg = GetForegroundWindow();
167
189
  uint dummy;
168
190
  uint fgt = GetWindowThreadProcessId(fg, out dummy);
169
191
  uint myt = GetCurrentThreadId();
170
192
  AttachThreadInput(myt, fgt, true);
171
- ShowWindow(h, 9); // SW_RESTORE
172
193
  BringWindowToTop(h);
173
194
  bool ok = SetForegroundWindow(h);
174
195
  AttachThreadInput(myt, fgt, false);
package/src/dsh/index.mjs CHANGED
@@ -46,6 +46,8 @@ import {
46
46
  import { calibrateAnchors, inspectAnchors } from '../core/calibrate.mjs'
47
47
  import { pushConfig, pushConfigMany, queryDevice } from '../core/provision.mjs'
48
48
  import { SCENARIOS, SCENARIO_IDS, renderScenario, searchScenarios, byLevel } from '../core/presets.mjs'
49
+ import { doctor, modelsOf } from '../core/doctor.mjs'
50
+ import { runAcceptance, hasChecks } from '../core/acceptance.mjs'
49
51
  import { VERSION } from '../core/version.mjs'
50
52
 
51
53
  /** Declared hard dependency: the tool registry. */
@@ -550,7 +552,14 @@ export function apply(ctx) {
550
552
  const brief = hasLab(id)
551
553
  ? renderScenario(id) + `\n\n可生成拓扑: ensp_build_topology { path: "${id}.topo", scenario: "${id}" }`
552
554
  : renderScenario(id)
553
- return { id, brief }
555
+ return {
556
+ id,
557
+ brief:
558
+ brief +
559
+ (hasChecks(id)
560
+ ? `\n\n验收: ensp_verify { scenario: "${id}", topology: "${id}.topo" }`
561
+ : '\n\n验收: 无专门断言,ensp_verify 会逐条跑场景里的验证命令'),
562
+ }
554
563
  },
555
564
  })
556
565
 
@@ -615,6 +624,101 @@ export function apply(ctx) {
615
624
  }
616
625
  },
617
626
  })
627
+
628
+ /* ----------------------------------------------------------------- doctor */
629
+
630
+ add(tools, {
631
+ name: 'ensp_doctor',
632
+ description:
633
+ 'Check whether this machine can actually run a lab before starting anything: eNSP and '
634
+ + 'VirtualBox presence, missing device images, console ports already in use, free memory, and '
635
+ + 'whether synthetic mouse input reaches this session at all. Every failing check comes with a '
636
+ + 'fix. Never starts eNSP, so it is safe to run first.',
637
+ parameters: {
638
+ topology: str('optional .topo path, so the device images it needs can be checked'),
639
+ deep: bool('run the slow input-injection probe (default true)'),
640
+ },
641
+ output: {
642
+ schema: obj({
643
+ ok: req(bool()),
644
+ failed: req(str()),
645
+ warned: req(str()),
646
+ checks: req(str()),
647
+ error: req(str()),
648
+ }),
649
+ render(args, value) {
650
+ if (value.error) return text(value.error)
651
+ return text(value.checks + '\n\n' + (value.ok ? 'environment ok' : 'environment NOT ready'))
652
+ },
653
+ },
654
+ async execute({ topology, deep }) {
655
+ let models
656
+ if (topology) models = modelsOf(await readTopology(path.resolve(topology)))
657
+ const report = doctor({ models, deep: deep !== false })
658
+ const lines = report.checks.map((c) => {
659
+ const tag = c.status === 'pass' ? '[ok ]' : c.status === 'warn' ? '[warn]' : '[FAIL]'
660
+ return `${tag} ${c.id}: ${c.detail}` + (c.fix ? `\n fix: ${c.fix}` : '')
661
+ })
662
+ return {
663
+ ok: report.ok,
664
+ failed: report.failed.join(', ') || '(none)',
665
+ warned: report.warned.join(', ') || '(none)',
666
+ checks: lines.join('\n'),
667
+ }
668
+ },
669
+ })
670
+
671
+ /* ----------------------------------------------------------------- verify */
672
+
673
+ add(tools, {
674
+ name: 'ensp_verify',
675
+ description:
676
+ 'Run a scenario\'s acceptance checks against a lab that is already running. Curated scenarios '
677
+ + 'assert on what each device reports (OSPF Full, BGP Established, VRRP Master); the rest '
678
+ + 'smoke-test every verification command in the scenario. Use this to decide whether the lab '
679
+ + 'works, rather than whether configuration was merely sent.',
680
+ parameters: {
681
+ scenario: str('scenario id, e.g. "ospf-single"'),
682
+ topology: str('the .topo file that is running, used for console ports'),
683
+ timeout: num('per-command timeout in ms (default 25000)'),
684
+ wait: num('how long a failing assertion may keep being retried, in ms (default 60000)'),
685
+ },
686
+ output: {
687
+ schema: obj({
688
+ scenario: req(str()),
689
+ ok: req(bool()),
690
+ summary: req(str()),
691
+ detail: req(str()),
692
+ error: req(str()),
693
+ }),
694
+ render(args, value) {
695
+ if (value.error) return text(value.error)
696
+ return text(`${value.scenario} · ${value.summary}\n\n${value.detail}`)
697
+ },
698
+ },
699
+ async execute({ scenario, topology, timeout, wait }) {
700
+ if (!scenario) throw new Error('scenario is required')
701
+ if (!topology) throw new Error('topology is required: pass the .topo file that is running')
702
+ const report = await runAcceptance({
703
+ id: scenario,
704
+ topology: path.resolve(topology),
705
+ timeoutMs: timeout,
706
+ waitMs: wait,
707
+ })
708
+ const detail = report.results
709
+ .map((r) => {
710
+ const waited = r.pass && r.waitedMs ? ` (converged after ${Math.round(r.waitedMs / 1000)}s)` : ''
711
+ return `${r.pass ? 'PASS' : 'FAIL'} ${r.device} · ${r.id}${waited} — ${r.why}`
712
+ })
713
+ .join('\n')
714
+ return {
715
+ scenario: report.scenario,
716
+ ok: report.ok,
717
+ summary: `${report.passed}/${report.total} checks passed (${report.source})`,
718
+ detail,
719
+ }
720
+ },
721
+ })
618
722
  }
619
723
 
620
724
  /** Model metadata for the plugin row. */
@@ -38,6 +38,8 @@ import {
38
38
  waitForConsole,
39
39
  } from '../core/launcher.mjs'
40
40
  import { calibrateAnchors, inspectAnchors, DEFAULT_ANCHORS } from '../core/calibrate.mjs'
41
+ import { doctor, modelsOf } from '../core/doctor.mjs'
42
+ import { runAcceptance, hasChecks, EXPECTATIONS } from '../core/acceptance.mjs'
41
43
  import { pushConfig, pushConfigMany, queryDevice } from '../core/provision.mjs'
42
44
  import { SCENARIOS, SCENARIO_IDS, byLevel, renderScenario, searchScenarios } from '../core/presets.mjs'
43
45
  import { PACKAGE_NAME, VERSION } from '../core/version.mjs'
@@ -327,10 +329,50 @@ const TOOLS = [
327
329
  brief: renderScenario(id),
328
330
  data: SCENARIOS[id],
329
331
  hasTopology: hasLab(id),
332
+ hasCuratedChecks: hasChecks(id),
330
333
  ...(hasLab(id) ? { devices: labDevices(id), addressing: labAddressing(id) } : {}),
331
334
  }
332
335
  },
333
336
  },
337
+ {
338
+ name: 'ensp_doctor',
339
+ description:
340
+ 'Check whether this machine can actually run a lab, before starting anything. ' +
341
+ 'Reports eNSP and VirtualBox presence, missing device images, console ports already in ' +
342
+ 'use, free memory, and — most importantly — whether synthetic mouse input reaches this ' +
343
+ 'session at all. Every failing check comes with the fix. Never starts eNSP.',
344
+ inputSchema: obj({
345
+ topology: { type: 'string', description: 'optional .topo path, so device images can be checked' },
346
+ deep: { type: 'boolean', description: 'run the slow input-injection probe (default true)' },
347
+ }),
348
+ async run({ topology, deep }) {
349
+ let models
350
+ if (topology) models = modelsOf(await readTopology(topology))
351
+ return doctor({ models, deep: deep !== false })
352
+ },
353
+ },
354
+ {
355
+ name: 'ensp_verify',
356
+ description:
357
+ 'Run a scenario\'s acceptance checks against a lab that is already running. Curated ' +
358
+ 'scenarios assert on what each device reports (OSPF Full, BGP Established, VRRP Master); ' +
359
+ 'the rest smoke-test every verification command from the scenario. Use this to decide ' +
360
+ 'whether the lab works, not just whether configuration was sent.',
361
+ inputSchema: obj({
362
+ scenario: { type: 'string', description: 'scenario id, e.g. "ospf-single"' },
363
+ topology: { type: 'string', description: 'the .topo file that is running (for console ports)' },
364
+ timeout: { type: 'number', description: 'per-command timeout in ms (default 25000)' },
365
+ wait: {
366
+ type: 'number',
367
+ description: 'how long a failing assertion may keep being retried, in ms (default 60000)',
368
+ },
369
+ }),
370
+ async run({ scenario, topology, timeout, wait }) {
371
+ if (!scenario) throw new Error('scenario is required')
372
+ if (!topology) throw new Error('topology is required: pass the .topo file that is running')
373
+ return runAcceptance({ id: scenario, topology, timeoutMs: timeout, waitMs: wait })
374
+ },
375
+ },
334
376
  {
335
377
  name: 'ensp_preset_config',
336
378
  description: 'Return the configuration that makes the built-in two-PC lab pass traffic.',