dsh-ensp 0.1.2 → 0.1.6
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.md +212 -274
- package/docs/plugin-detail.en.md +14 -0
- package/docs/plugin-detail.zh.md +14 -0
- package/package.json +5 -15
- package/screenshots.json +3 -0
- package/src/core/index.mjs +9 -1
- package/src/core/launcher.mjs +284 -24
- package/src/core/provision.mjs +81 -0
- package/src/core/topology.mjs +23 -5
- package/src/dsh/index.mjs +595 -387
package/src/dsh/index.mjs
CHANGED
|
@@ -1,387 +1,595 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DeepSeek Harness plugin row.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
{
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
},
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
},
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek Harness plugin row.
|
|
3
|
+
*
|
|
4
|
+
* A thin adapter: it publishes the same operations the CLI and the MCP server
|
|
5
|
+
* expose as **model tools**, so an agent can drive an eNSP lab inside a Harness
|
|
6
|
+
* session. No behaviour lives here; everything delegates to `src/core`.
|
|
7
|
+
*
|
|
8
|
+
* ## Tool contract
|
|
9
|
+
*
|
|
10
|
+
* `ctx.tools.register` wants a shape that differs from the MCP schema in two ways,
|
|
11
|
+
* and getting either wrong fails activation with a schema complaint rather than a
|
|
12
|
+
* stack trace:
|
|
13
|
+
*
|
|
14
|
+
* - `parameters` is a **flat map of field name to field schema**, not a JSON
|
|
15
|
+
* Schema object with `properties`.
|
|
16
|
+
* - `output` is **required** and pairs a closed `schema` with a `render`
|
|
17
|
+
* function that turns the handler's value into content blocks.
|
|
18
|
+
*
|
|
19
|
+
* The helpers below mirror what the first-party plugins do, so the two front-ends
|
|
20
|
+
* stay readable side by side.
|
|
21
|
+
*
|
|
22
|
+
* @module dsh-ensp/dsh
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFile, readdir } from 'node:fs/promises'
|
|
26
|
+
import path from 'node:path'
|
|
27
|
+
|
|
28
|
+
import { buildTopology, twoPcLab, twoPcLabConfig, autoLayout } from '../core/build.mjs'
|
|
29
|
+
import { readTopology, writeTopology, summarise } from '../core/topology.mjs'
|
|
30
|
+
import {
|
|
31
|
+
enspPid,
|
|
32
|
+
locateWindow,
|
|
33
|
+
launchEnsp,
|
|
34
|
+
startAllDevices,
|
|
35
|
+
stopAllDevices,
|
|
36
|
+
waitForConsole,
|
|
37
|
+
} from '../core/launcher.mjs'
|
|
38
|
+
import { calibrateAnchors, inspectAnchors } from '../core/calibrate.mjs'
|
|
39
|
+
import { pushConfig, pushConfigMany, queryDevice } from '../core/provision.mjs'
|
|
40
|
+
import { SCENARIOS, renderScenario, searchScenarios, byLevel } from '../core/presets.mjs'
|
|
41
|
+
|
|
42
|
+
/** Declared hard dependency: the tool registry. */
|
|
43
|
+
export const inject = ['tools']
|
|
44
|
+
|
|
45
|
+
export const name = 'ensp'
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------- schema helpers
|
|
48
|
+
//
|
|
49
|
+
// `req()` marks a field as required for the *author*, and `obj()` compiles the
|
|
50
|
+
// marker away into the JSON-Schema form the validator accepts: a top-level
|
|
51
|
+
// `required` array of names, never a `required: true` on the leaf.
|
|
52
|
+
//
|
|
53
|
+
// Leaving the marker on the leaf fails activation with
|
|
54
|
+
// unsupported JSON schema: schema.properties.x.required is not supported on type "boolean"
|
|
55
|
+
|
|
56
|
+
const str = (description) => ({ type: 'string', ...(description ? { description } : {}) })
|
|
57
|
+
const num = (description) => ({ type: 'number', ...(description ? { description } : {}) })
|
|
58
|
+
const bool = (description) => ({ type: 'boolean', ...(description ? { description } : {}) })
|
|
59
|
+
const arr = (description) => ({ type: 'array', items: { type: 'object' }, ...(description ? { description } : {}) })
|
|
60
|
+
|
|
61
|
+
/** Mark an output field required. The marker is consumed by `obj()`. */
|
|
62
|
+
const req = (node) => Object.assign(node, { required: true })
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Turn an author-facing property map into a closed JSON Schema object,
|
|
66
|
+
* hoisting every leaf `required: true` into the object-level array.
|
|
67
|
+
*/
|
|
68
|
+
function compileObject(properties) {
|
|
69
|
+
const compiled = {}
|
|
70
|
+
const required = []
|
|
71
|
+
for (const key of Object.keys(properties ?? {})) {
|
|
72
|
+
const child = properties[key]
|
|
73
|
+
if (child && typeof child === 'object' && child.required === true) required.push(key)
|
|
74
|
+
compiled[key] = stripRequired(child)
|
|
75
|
+
}
|
|
76
|
+
const schema = { type: 'object', additionalProperties: false, properties: compiled }
|
|
77
|
+
if (required.length) schema.required = required
|
|
78
|
+
return schema
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Deep-copy a node without the internal `required` marker. */
|
|
82
|
+
function stripRequired(node) {
|
|
83
|
+
if (node === null || typeof node !== 'object') return node
|
|
84
|
+
if (Array.isArray(node)) return node.map(stripRequired)
|
|
85
|
+
const out = {}
|
|
86
|
+
for (const [key, value] of Object.entries(node)) {
|
|
87
|
+
if (key === 'required') continue
|
|
88
|
+
out[key] = stripRequired(value)
|
|
89
|
+
}
|
|
90
|
+
return out
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Declare a closed output object. */
|
|
94
|
+
const obj = (properties) => compileObject(properties)
|
|
95
|
+
|
|
96
|
+
/** One plain text content block. */
|
|
97
|
+
const text = (value) => [{ type: 'text', text: String(value) }]
|
|
98
|
+
|
|
99
|
+
/** Render a JSON payload for display without depending on any UI helper. */
|
|
100
|
+
const showJson = (value) => text(JSON.stringify(value, null, 2))
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Register one tool with the harness.
|
|
104
|
+
*
|
|
105
|
+
* @param {object} tools - `ctx.tools`
|
|
106
|
+
* @param {object} definition - `{ name, description, parameters, output, execute }`
|
|
107
|
+
*/
|
|
108
|
+
function add(tools, definition) {
|
|
109
|
+
const { execute, ...rest } = definition
|
|
110
|
+
return tools.register({
|
|
111
|
+
...rest,
|
|
112
|
+
async execute(args) {
|
|
113
|
+
return execute(args ?? {})
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build the plugin.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} ctx - Cordis context carrying the declared services.
|
|
122
|
+
*/
|
|
123
|
+
export function apply(ctx) {
|
|
124
|
+
const tools = ctx.tools
|
|
125
|
+
if (!tools?.register) {
|
|
126
|
+
console.warn('[dsh-ensp] tools service unavailable; plugin inactive')
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* ------------------------------------------------------------------ status */
|
|
131
|
+
|
|
132
|
+
add(tools, {
|
|
133
|
+
name: 'ensp_status',
|
|
134
|
+
description:
|
|
135
|
+
'Report eNSP state: whether the client is running, its window handle, and the calibrated toolbar anchors. '
|
|
136
|
+
+ 'With a topology path, also polls every device console and reports which ones answer VRP.',
|
|
137
|
+
parameters: {
|
|
138
|
+
topology: str('Optional .topo path; when set, each console is probed (max 4s each).'),
|
|
139
|
+
},
|
|
140
|
+
output: {
|
|
141
|
+
schema: obj({
|
|
142
|
+
running: req(bool()),
|
|
143
|
+
pid: req(num()),
|
|
144
|
+
hwnd: req(str()),
|
|
145
|
+
anchors: req(str()),
|
|
146
|
+
devices: req(str()),
|
|
147
|
+
error: req(str()),
|
|
148
|
+
}),
|
|
149
|
+
render(args, value) {
|
|
150
|
+
if (!value.running) return text('eNSP is not running.')
|
|
151
|
+
const lines = [`eNSP running, pid ${value.pid}, hwnd ${value.hwnd || '(no frame)'}`]
|
|
152
|
+
if (value.anchors) lines.push(value.anchors)
|
|
153
|
+
if (value.devices) lines.push('', value.devices)
|
|
154
|
+
if (value.error) lines.push('', value.error)
|
|
155
|
+
return text(lines.join('\n'))
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
async execute({ topology }) {
|
|
159
|
+
const pid = enspPid()
|
|
160
|
+
if (!pid) return { running: false }
|
|
161
|
+
const hwnd = locateWindow(pid)
|
|
162
|
+
let anchors = ''
|
|
163
|
+
if (hwnd) {
|
|
164
|
+
const a = calibrateAnchors(hwnd)
|
|
165
|
+
anchors = `toolbar anchors (${a.method}): start ${JSON.stringify(a.start)}, stop ${JSON.stringify(a.stop)}`
|
|
166
|
+
}
|
|
167
|
+
let devices = ''
|
|
168
|
+
if (topology) {
|
|
169
|
+
const topo = await readTopology(topology)
|
|
170
|
+
const rows = await Promise.all(
|
|
171
|
+
topo.devices
|
|
172
|
+
.filter((d) => d.comPort)
|
|
173
|
+
.map(async (d) => {
|
|
174
|
+
const r = await waitForConsole({ port: d.comPort, timeoutMs: 4000 })
|
|
175
|
+
return `${r.ready ? 'ready' : 'no answer'} ${d.name} :${d.comPort} ${r.prompt ?? ''}`.trimEnd()
|
|
176
|
+
}),
|
|
177
|
+
)
|
|
178
|
+
devices = rows.join('\n')
|
|
179
|
+
}
|
|
180
|
+
return { running: true, pid, hwnd: hwnd ?? '', anchors, devices }
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
/* -------------------------------------------------------------- build topo */
|
|
185
|
+
|
|
186
|
+
add(tools, {
|
|
187
|
+
name: 'ensp_build_topology',
|
|
188
|
+
description:
|
|
189
|
+
'Generate a .topo file eNSP can open. Use preset "two-pc" for the built-in two-PC lab, '
|
|
190
|
+
+ 'or pass devices and links (links use VRP interface names such as GigabitEthernet0/0/0).',
|
|
191
|
+
parameters: {
|
|
192
|
+
path: str('Output .topo path.'),
|
|
193
|
+
preset: str('"two-pc" for the built-in lab.'),
|
|
194
|
+
vlan: num('VLAN id for the two-pc preset (default 10).'),
|
|
195
|
+
devices: arr('Device list: [{ name, model, x, y }].'),
|
|
196
|
+
links: arr('Link list: [{ a, aPort, b, bPort }].'),
|
|
197
|
+
},
|
|
198
|
+
output: {
|
|
199
|
+
schema: obj({
|
|
200
|
+
path: req(str()),
|
|
201
|
+
devices: req(num()),
|
|
202
|
+
links: req(num()),
|
|
203
|
+
consoles: req(str()),
|
|
204
|
+
error: req(str()),
|
|
205
|
+
}),
|
|
206
|
+
render(args, value) {
|
|
207
|
+
if (value.error) return text(value.error)
|
|
208
|
+
return text(
|
|
209
|
+
`wrote ${value.path}\n${value.devices} devices, ${value.links} links\nconsoles: ${value.consoles}`,
|
|
210
|
+
)
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
async execute(args) {
|
|
214
|
+
if (!args.path) throw new Error('path is required')
|
|
215
|
+
let topo
|
|
216
|
+
if (args.preset === 'two-pc' || (!args.devices && !args.links)) {
|
|
217
|
+
topo = twoPcLab({ vlan: args.vlan ?? 10 })
|
|
218
|
+
} else {
|
|
219
|
+
const b = buildTopology()
|
|
220
|
+
for (const d of args.devices ?? []) b.add(d.name, d.model, { x: d.x, y: d.y, pc: d.pc })
|
|
221
|
+
for (const l of args.links ?? []) b.link(l.a, l.aPort, l.b, l.bPort)
|
|
222
|
+
topo = autoLayout(b.finish())
|
|
223
|
+
}
|
|
224
|
+
const target = path.resolve(args.path)
|
|
225
|
+
await writeTopology(target, topo)
|
|
226
|
+
const info = summarise(topo)
|
|
227
|
+
return {
|
|
228
|
+
path: target,
|
|
229
|
+
devices: info.devices,
|
|
230
|
+
links: info.links,
|
|
231
|
+
consoles: JSON.stringify(info.consoles),
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
/* ------------------------------------------------------------------- open */
|
|
237
|
+
|
|
238
|
+
add(tools, {
|
|
239
|
+
name: 'ensp_open',
|
|
240
|
+
description: 'Launch the eNSP client (or reuse the running one) and open a topology file.',
|
|
241
|
+
parameters: { topology: str('Absolute path to a .topo file.') },
|
|
242
|
+
output: {
|
|
243
|
+
schema: obj({
|
|
244
|
+
pid: req(num()),
|
|
245
|
+
hwnd: req(str()),
|
|
246
|
+
reused: req(bool()),
|
|
247
|
+
error: req(str()),
|
|
248
|
+
}),
|
|
249
|
+
render(args, value) {
|
|
250
|
+
return text(`eNSP pid ${value.pid}, hwnd ${value.hwnd}${value.reused ? ' (reused)' : ' (launched)'}`)
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
async execute({ topology }) {
|
|
254
|
+
if (!topology) throw new Error('topology is required')
|
|
255
|
+
const r = await launchEnsp({ topology: path.resolve(topology) })
|
|
256
|
+
return { pid: r.pid, hwnd: r.hwnd, reused: Boolean(r.reused) }
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
/* ------------------------------------------------------------------ start */
|
|
261
|
+
|
|
262
|
+
add(tools, {
|
|
263
|
+
name: 'ensp_start',
|
|
264
|
+
description:
|
|
265
|
+
'Boot every device in a topology. Calibrates the toolbar by colour, clicks start, then polls each '
|
|
266
|
+
+ 'console until VRP answers, so readiness is measured per device rather than assumed. The window is '
|
|
267
|
+
+ 'minimised afterwards unless keepWindow is true.',
|
|
268
|
+
parameters: {
|
|
269
|
+
topology: str('Absolute path to the .topo file that is currently open.'),
|
|
270
|
+
perDeviceTimeoutMs: num('Per-device readiness ceiling in ms (default 240000).'),
|
|
271
|
+
keepWindow: bool('Leave the eNSP window visible after starting.'),
|
|
272
|
+
},
|
|
273
|
+
output: {
|
|
274
|
+
schema: obj({
|
|
275
|
+
allReady: req(bool()),
|
|
276
|
+
ready: req(num()),
|
|
277
|
+
total: req(num()),
|
|
278
|
+
anchors: req(str()),
|
|
279
|
+
devices: req(str()),
|
|
280
|
+
error: req(str()),
|
|
281
|
+
}),
|
|
282
|
+
render(args, value) {
|
|
283
|
+
if (value.error) return text(value.error)
|
|
284
|
+
const head = `${value.ready}/${value.total} devices ready${value.allReady ? '' : ' (some did not answer)'}`
|
|
285
|
+
return text([head, value.anchors, '', value.devices].filter(Boolean).join('\n'))
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
async execute(args) {
|
|
289
|
+
if (!args.topology) throw new Error('topology is required')
|
|
290
|
+
const topo = await readTopology(path.resolve(args.topology))
|
|
291
|
+
const hwnd = locateWindow(enspPid())
|
|
292
|
+
if (!hwnd) throw new Error('eNSP window not found; call ensp_open first')
|
|
293
|
+
const r = await startAllDevices({
|
|
294
|
+
devices: topo.devices,
|
|
295
|
+
hwnd,
|
|
296
|
+
perDeviceTimeoutMs: args.perDeviceTimeoutMs ?? 240000,
|
|
297
|
+
minimise: !args.keepWindow,
|
|
298
|
+
})
|
|
299
|
+
return {
|
|
300
|
+
allReady: r.allReady,
|
|
301
|
+
ready: r.ready,
|
|
302
|
+
total: r.total,
|
|
303
|
+
anchors: `toolbar anchors (${r.anchors?.method ?? 'default'}): ${JSON.stringify(r.anchors?.start ?? {})}`,
|
|
304
|
+
devices: r.devices
|
|
305
|
+
.map((d) => `${d.ready ? 'ready' : 'no answer'} ${d.name} :${d.port} ${d.prompt ?? ''}`.trimEnd())
|
|
306
|
+
.join('\n'),
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
/* ------------------------------------------------------------------- stop */
|
|
312
|
+
|
|
313
|
+
add(tools, {
|
|
314
|
+
name: 'ensp_stop',
|
|
315
|
+
description: 'Shut down every device in the running topology.',
|
|
316
|
+
parameters: {},
|
|
317
|
+
output: {
|
|
318
|
+
schema: obj({
|
|
319
|
+
ok: req(bool()),
|
|
320
|
+
anchors: req(str()),
|
|
321
|
+
error: req(str()),
|
|
322
|
+
}),
|
|
323
|
+
render(args, value) {
|
|
324
|
+
if (value.error) return text(value.error)
|
|
325
|
+
return text(`shutdown requested for all devices\n${value.anchors}`)
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
async execute() {
|
|
329
|
+
const hwnd = locateWindow(enspPid())
|
|
330
|
+
if (!hwnd) throw new Error('eNSP window not found')
|
|
331
|
+
const r = stopAllDevices(hwnd)
|
|
332
|
+
return { ok: r.ok, anchors: `anchors (${r.anchors?.method ?? 'default'}): ${JSON.stringify(r.anchors?.stop ?? {})}` }
|
|
333
|
+
},
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
/* ------------------------------------------------------------------ apply */
|
|
337
|
+
|
|
338
|
+
add(tools, {
|
|
339
|
+
name: 'ensp_apply',
|
|
340
|
+
description:
|
|
341
|
+
'Push a configuration script to one device, one command at a time. Each command is confirmed by its '
|
|
342
|
+
+ 'prompt before the next is sent; the first error aborts the batch and reports the line, the command '
|
|
343
|
+
+ 'and the raw device output.',
|
|
344
|
+
parameters: {
|
|
345
|
+
device: str('Device name, used in the report.'),
|
|
346
|
+
port: num('Console TCP port from the .topo file.'),
|
|
347
|
+
script: str('Configuration text; blank lines and ! / # comments are skipped.'),
|
|
348
|
+
save: bool('Run save at the end.'),
|
|
349
|
+
},
|
|
350
|
+
output: {
|
|
351
|
+
schema: obj({
|
|
352
|
+
ok: req(bool()),
|
|
353
|
+
applied: req(num()),
|
|
354
|
+
total: req(num()),
|
|
355
|
+
saved: req(bool()),
|
|
356
|
+
failedAt: req(str()),
|
|
357
|
+
error: req(str()),
|
|
358
|
+
}),
|
|
359
|
+
render(args, value) {
|
|
360
|
+
if (value.error) return text(value.error)
|
|
361
|
+
if (value.ok) {
|
|
362
|
+
return text(`${args.device}: ${value.applied}/${value.total} commands applied${value.saved ? ', saved' : ''}`)
|
|
363
|
+
}
|
|
364
|
+
return text(`${args.device}: stopped after ${value.applied}/${value.total} commands\n${value.failedAt}`)
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
async execute(args) {
|
|
368
|
+
if (!args.port) throw new Error('port is required')
|
|
369
|
+
const r = await pushConfig({
|
|
370
|
+
device: args.device ?? 'device',
|
|
371
|
+
port: args.port,
|
|
372
|
+
script: args.script ?? '',
|
|
373
|
+
save: Boolean(args.save),
|
|
374
|
+
})
|
|
375
|
+
return {
|
|
376
|
+
ok: r.ok,
|
|
377
|
+
applied: r.applied,
|
|
378
|
+
total: r.total,
|
|
379
|
+
saved: r.saved,
|
|
380
|
+
failedAt: r.failedAt
|
|
381
|
+
? `line ${r.failedAt.line}: ${r.failedAt.command}\n ${r.failedAt.error}\n ${(r.failedAt.output ?? '').trim().split('\n').slice(-6).join('\n ')}`
|
|
382
|
+
: '',
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
/* -------------------------------------------------------------- apply dir */
|
|
388
|
+
|
|
389
|
+
add(tools, {
|
|
390
|
+
name: 'ensp_apply_dir',
|
|
391
|
+
description:
|
|
392
|
+
'Push one .cfg per device from a directory, matching file names to device names. Devices are '
|
|
393
|
+
+ 'configured concurrently and one failure does not stop the others.',
|
|
394
|
+
parameters: {
|
|
395
|
+
directory: str('Directory containing <device>.cfg files.'),
|
|
396
|
+
topology: str('Topology that maps device names to console ports.'),
|
|
397
|
+
save: bool('Run save on each device afterwards.'),
|
|
398
|
+
concurrency: num('How many devices to configure at once (default 3).'),
|
|
399
|
+
},
|
|
400
|
+
output: {
|
|
401
|
+
schema: obj({
|
|
402
|
+
configured: req(num()),
|
|
403
|
+
total: req(num()),
|
|
404
|
+
results: req(str()),
|
|
405
|
+
error: req(str()),
|
|
406
|
+
}),
|
|
407
|
+
render(args, value) {
|
|
408
|
+
if (value.error) return text(value.error)
|
|
409
|
+
return text(`${value.configured}/${value.total} devices configured\n\n${value.results}`)
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
async execute(args) {
|
|
413
|
+
if (!args.directory || !args.topology) throw new Error('directory and topology are required')
|
|
414
|
+
const topo = await readTopology(path.resolve(args.topology))
|
|
415
|
+
const consoles = new Map(topo.devices.filter((d) => d.comPort).map((d) => [d.name, d.comPort]))
|
|
416
|
+
const files = (await readdir(args.directory)).filter((f) => /\.(cfg|txt)$/i.test(f))
|
|
417
|
+
const targets = []
|
|
418
|
+
for (const file of files) {
|
|
419
|
+
const device = file.replace(/\.(cfg|txt)$/i, '')
|
|
420
|
+
const port = consoles.get(device)
|
|
421
|
+
if (!port) continue
|
|
422
|
+
targets.push({ device, port, script: await readFile(path.join(args.directory, file), 'utf8') })
|
|
423
|
+
}
|
|
424
|
+
const results = await pushConfigMany({
|
|
425
|
+
targets,
|
|
426
|
+
concurrency: args.concurrency ?? 3,
|
|
427
|
+
save: Boolean(args.save),
|
|
428
|
+
})
|
|
429
|
+
return {
|
|
430
|
+
configured: results.filter((r) => r.ok).length,
|
|
431
|
+
total: results.length,
|
|
432
|
+
results: results
|
|
433
|
+
.map((r) =>
|
|
434
|
+
r.ok
|
|
435
|
+
? `ok ${r.device} ${r.applied}/${r.total}`
|
|
436
|
+
: `FAIL ${r.device} line ${r.failedAt?.line}: ${r.failedAt?.command} -> ${r.failedAt?.error}`,
|
|
437
|
+
)
|
|
438
|
+
.join('\n'),
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
/* ------------------------------------------------------------------ query */
|
|
444
|
+
|
|
445
|
+
add(tools, {
|
|
446
|
+
name: 'ensp_query',
|
|
447
|
+
description: 'Run one read-only command on a device console and return its output.',
|
|
448
|
+
parameters: {
|
|
449
|
+
port: num('Console TCP port.'),
|
|
450
|
+
command: str('One VRP command, e.g. "display ip routing-table".'),
|
|
451
|
+
},
|
|
452
|
+
output: {
|
|
453
|
+
schema: obj({
|
|
454
|
+
ok: req(bool()),
|
|
455
|
+
output: req(str()),
|
|
456
|
+
error: req(str()),
|
|
457
|
+
}),
|
|
458
|
+
render(args, value) {
|
|
459
|
+
if (!value.ok) return text(`${args.command} failed: ${value.error ?? 'unknown error'}`)
|
|
460
|
+
return text(value.output)
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
async execute(args) {
|
|
464
|
+
if (!args.port || !args.command) throw new Error('port and command are required')
|
|
465
|
+
const r = await queryDevice({ port: args.port, command: args.command })
|
|
466
|
+
return { ok: r.ok, output: r.output, error: r.error ?? '' }
|
|
467
|
+
},
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
/* -------------------------------------------------------------- scenarios */
|
|
471
|
+
|
|
472
|
+
add(tools, {
|
|
473
|
+
name: 'ensp_scenarios',
|
|
474
|
+
description:
|
|
475
|
+
'List or search the 23 built-in configuration scenarios. Each is a skeleton: what to create, in what '
|
|
476
|
+
+ 'order, and the command that proves it worked, rather than a command dump.',
|
|
477
|
+
parameters: {
|
|
478
|
+
search: str('Keyword filter, e.g. "ospf" or "vlan".'),
|
|
479
|
+
level: str('One of basic, intermediate, advanced.'),
|
|
480
|
+
},
|
|
481
|
+
output: {
|
|
482
|
+
schema: obj({
|
|
483
|
+
count: req(num()),
|
|
484
|
+
list: req(str()),
|
|
485
|
+
error: req(str()),
|
|
486
|
+
}),
|
|
487
|
+
render(args, value) {
|
|
488
|
+
return text(value.list || '(no scenario matched)')
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
async execute({ search, level }) {
|
|
492
|
+
const groups = byLevel()
|
|
493
|
+
let ids = level && groups[level] ? groups[level] : Object.keys(SCENARIOS)
|
|
494
|
+
if (search) {
|
|
495
|
+
const matched = new Set(searchScenarios(search).map((h) => h.id))
|
|
496
|
+
ids = ids.filter((id) => matched.has(id))
|
|
497
|
+
}
|
|
498
|
+
return {
|
|
499
|
+
count: ids.length,
|
|
500
|
+
list: ids
|
|
501
|
+
.map((id) => `${id.padEnd(24)} ${SCENARIOS[id].title} [${SCENARIOS[id].level}]`)
|
|
502
|
+
.join('\n'),
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
add(tools, {
|
|
508
|
+
name: 'ensp_scenario',
|
|
509
|
+
description: 'Return one scenario as a step-by-step brief with verification commands and pitfalls.',
|
|
510
|
+
parameters: { id: str('Scenario id, e.g. "ospf-single".') },
|
|
511
|
+
output: {
|
|
512
|
+
schema: obj({
|
|
513
|
+
id: req(str()),
|
|
514
|
+
brief: req(str()),
|
|
515
|
+
error: req(str()),
|
|
516
|
+
}),
|
|
517
|
+
render(args, value) {
|
|
518
|
+
return text(value.brief)
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
async execute({ id }) {
|
|
522
|
+
if (!SCENARIOS[id]) throw new Error(`unknown scenario "${id}"`)
|
|
523
|
+
return { id, brief: renderScenario(id) }
|
|
524
|
+
},
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
/* ---------------------------------------------------------- preset config */
|
|
528
|
+
|
|
529
|
+
add(tools, {
|
|
530
|
+
name: 'ensp_preset_config',
|
|
531
|
+
description: 'Return the configuration text for the built-in two-PC lab.',
|
|
532
|
+
parameters: { vlan: num('VLAN id (default 10).') },
|
|
533
|
+
output: {
|
|
534
|
+
schema: obj({
|
|
535
|
+
script: req(str()),
|
|
536
|
+
error: req(str()),
|
|
537
|
+
}),
|
|
538
|
+
render(args, value) {
|
|
539
|
+
return text(value.script)
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
async execute({ vlan }) {
|
|
543
|
+
return { script: twoPcLabConfig({ vlan: vlan ?? 10 }) }
|
|
544
|
+
},
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
/* ------------------------------------------------------------------ probe */
|
|
548
|
+
|
|
549
|
+
add(tools, {
|
|
550
|
+
name: 'ensp_probe',
|
|
551
|
+
description:
|
|
552
|
+
'Re-measure the toolbar anchors and report every colour cluster. Run this after an eNSP upgrade or a '
|
|
553
|
+
+ 'change of display scaling; results are cached per window size otherwise.',
|
|
554
|
+
parameters: {},
|
|
555
|
+
output: {
|
|
556
|
+
schema: obj({
|
|
557
|
+
window: req(str()),
|
|
558
|
+
start: req(str()),
|
|
559
|
+
stop: req(str()),
|
|
560
|
+
method: req(str()),
|
|
561
|
+
clusters: req(str()),
|
|
562
|
+
error: req(str()),
|
|
563
|
+
}),
|
|
564
|
+
render(args, value) {
|
|
565
|
+
if (value.error) return text(value.error)
|
|
566
|
+
return text(
|
|
567
|
+
`window ${value.window}\nanchors (${value.method}): start ${value.start}, stop ${value.stop}\n\n${value.clusters}`,
|
|
568
|
+
)
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
async execute() {
|
|
572
|
+
const hwnd = locateWindow(enspPid())
|
|
573
|
+
if (!hwnd) throw new Error('eNSP window not found')
|
|
574
|
+
const r = inspectAnchors(hwnd)
|
|
575
|
+
const resolved = r.resolved ?? { start: {}, stop: {}, method: 'none' }
|
|
576
|
+
return {
|
|
577
|
+
window: r.window ?? '(unknown)',
|
|
578
|
+
start: JSON.stringify(resolved.start),
|
|
579
|
+
stop: JSON.stringify(resolved.stop),
|
|
580
|
+
method: resolved.method,
|
|
581
|
+
clusters: [
|
|
582
|
+
`green (${r.green.length}): ` + r.green.map((c) => `${c.x},${c.y} ${c.w}x${c.h}`).join(' | '),
|
|
583
|
+
`red (${r.red.length}): ` + r.red.map((c) => `${c.x},${c.y} ${c.w}x${c.h}`).join(' | '),
|
|
584
|
+
].join('\n'),
|
|
585
|
+
}
|
|
586
|
+
},
|
|
587
|
+
})
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Model metadata for the plugin row. */
|
|
591
|
+
export const dsh = {
|
|
592
|
+
name: 'dsh-ensp',
|
|
593
|
+
version: '0.1.5',
|
|
594
|
+
description: 'Automate Huawei eNSP labs: build topology, boot devices, push config with verification.',
|
|
595
|
+
}
|