dsh-ensp 0.1.6 → 0.1.7
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 +56 -4
- package/docs/SCENARIOS.md +1410 -2
- package/docs/UI-ANCHORS.md +52 -0
- package/docs/plugin-detail.en.md +3 -2
- package/docs/plugin-detail.zh.md +3 -2
- package/package.json +5 -3
- package/scripts/gen-scenarios-doc.mjs +173 -0
- package/src/cli/ensp.mjs +462 -410
- package/src/core/build.mjs +218 -299
- package/src/core/compat.mjs +226 -0
- package/src/core/index.mjs +35 -3
- package/src/core/labs.mjs +1019 -0
- package/src/core/launcher.mjs +884 -725
- package/src/core/models.mjs +212 -0
- package/src/core/presets.mjs +583 -0
- package/src/core/topology.mjs +259 -333
- package/src/core/version.mjs +22 -0
- package/src/dsh/index.mjs +625 -595
- package/src/mcp/server.mjs +438 -406
package/src/cli/ensp.mjs
CHANGED
|
@@ -1,410 +1,462 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* `ensp` command line.
|
|
4
|
-
*
|
|
5
|
-
* Three verbs cover the whole workflow:
|
|
6
|
-
*
|
|
7
|
-
* ensp up open a topology and boot every device, verifying each one
|
|
8
|
-
* ensp apply push configuration files, one device at a time, with errors caught
|
|
9
|
-
* ensp status show what is running and which consoles answer
|
|
10
|
-
*
|
|
11
|
-
* Plus `ensp probe`, which re-measures the toolbar anchors — necessary after an
|
|
12
|
-
* eNSP upgrade or a change of display scaling.
|
|
13
|
-
*
|
|
14
|
-
* Exit codes: 0 success, 1 failure, 2 usage error.
|
|
15
|
-
*
|
|
16
|
-
* @module dsh-ensp/cli
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { readFile, access } from 'node:fs/promises'
|
|
20
|
-
import { constants } from 'node:fs'
|
|
21
|
-
import path from 'node:path'
|
|
22
|
-
import process from 'node:process'
|
|
23
|
-
|
|
24
|
-
import { readTopology, summarise } from '../core/topology.mjs'
|
|
25
|
-
import {
|
|
26
|
-
enspPid,
|
|
27
|
-
locateWindow,
|
|
28
|
-
startAllDevices,
|
|
29
|
-
stopAllDevices,
|
|
30
|
-
launchEnsp,
|
|
31
|
-
waitForConsole,
|
|
32
|
-
} from '../core/launcher.mjs'
|
|
33
|
-
import { calibrateAnchors, inspectAnchors, DEFAULT_ANCHORS } from '../core/calibrate.mjs'
|
|
34
|
-
import { pushConfig } from '../core/provision.mjs'
|
|
35
|
-
import { SCENARIOS, SCENARIO_IDS, byLevel, renderScenario, searchScenarios } from '../core/presets.mjs'
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
${
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
${C.bold('
|
|
58
|
-
--stop
|
|
59
|
-
|
|
60
|
-
--
|
|
61
|
-
--
|
|
62
|
-
--
|
|
63
|
-
--json
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
${C.bold('
|
|
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
|
-
function
|
|
128
|
-
const
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
hwnd
|
|
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
|
-
const
|
|
208
|
-
const
|
|
209
|
-
if (!
|
|
210
|
-
console.error(C.red('error:') +
|
|
211
|
-
return 2
|
|
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
|
-
const
|
|
272
|
-
console.log(
|
|
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
|
-
console.log(`${C.bold(
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `ensp` command line.
|
|
4
|
+
*
|
|
5
|
+
* Three verbs cover the whole workflow:
|
|
6
|
+
*
|
|
7
|
+
* ensp up open a topology and boot every device, verifying each one
|
|
8
|
+
* ensp apply push configuration files, one device at a time, with errors caught
|
|
9
|
+
* ensp status show what is running and which consoles answer
|
|
10
|
+
*
|
|
11
|
+
* Plus `ensp probe`, which re-measures the toolbar anchors — necessary after an
|
|
12
|
+
* eNSP upgrade or a change of display scaling.
|
|
13
|
+
*
|
|
14
|
+
* Exit codes: 0 success, 1 failure, 2 usage error.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-ensp/cli
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFile, access } from 'node:fs/promises'
|
|
20
|
+
import { constants } from 'node:fs'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
import process from 'node:process'
|
|
23
|
+
|
|
24
|
+
import { readTopology, summarise } from '../core/topology.mjs'
|
|
25
|
+
import {
|
|
26
|
+
enspPid,
|
|
27
|
+
locateWindow,
|
|
28
|
+
startAllDevices,
|
|
29
|
+
stopAllDevices,
|
|
30
|
+
launchEnsp,
|
|
31
|
+
waitForConsole,
|
|
32
|
+
} from '../core/launcher.mjs'
|
|
33
|
+
import { calibrateAnchors, inspectAnchors, DEFAULT_ANCHORS } from '../core/calibrate.mjs'
|
|
34
|
+
import { pushConfig } from '../core/provision.mjs'
|
|
35
|
+
import { SCENARIOS, SCENARIO_IDS, byLevel, renderScenario, searchScenarios } from '../core/presets.mjs'
|
|
36
|
+
import {
|
|
37
|
+
LAB_IDS,
|
|
38
|
+
buildScenarioTopology,
|
|
39
|
+
hasLab,
|
|
40
|
+
labAddressing,
|
|
41
|
+
labDevices,
|
|
42
|
+
} from '../core/labs.mjs'
|
|
43
|
+
import { checkTopologySupport } from '../core/compat.mjs'
|
|
44
|
+
import { writeTopology } from '../core/topology.mjs'
|
|
45
|
+
|
|
46
|
+
const C = {
|
|
47
|
+
dim: (s) => `\u001b[2m${s}\u001b[0m`,
|
|
48
|
+
green: (s) => `\u001b[32m${s}\u001b[0m`,
|
|
49
|
+
red: (s) => `\u001b[31m${s}\u001b[0m`,
|
|
50
|
+
yellow: (s) => `\u001b[33m${s}\u001b[0m`,
|
|
51
|
+
bold: (s) => `\u001b[1m${s}\u001b[0m`,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const HELP = `
|
|
55
|
+
${C.bold('ensp')} — automate Huawei eNSP labs
|
|
56
|
+
|
|
57
|
+
${C.bold('USAGE')}
|
|
58
|
+
ensp up <topology.topo> [--stop] [--keep-window]
|
|
59
|
+
ensp apply <config-dir> --topology <topology.topo> [--save] [--device NAME]
|
|
60
|
+
ensp status [--topology <topology.topo>]
|
|
61
|
+
ensp probe [--json]
|
|
62
|
+
ensp stop [--topology <topology.topo>]
|
|
63
|
+
ensp preset [<id>] [--search <keyword>] [--topo <out.topo>] [--json]
|
|
64
|
+
ensp mcp (start the MCP server on stdio)
|
|
65
|
+
|
|
66
|
+
${C.bold('OPTIONS')}
|
|
67
|
+
--stop stop all devices instead of starting them
|
|
68
|
+
--keep-window leave the eNSP window visible (default: minimise after clicking)
|
|
69
|
+
--save run "save" on each device after its configuration
|
|
70
|
+
--device NAME apply only to one device
|
|
71
|
+
--timeout MS per-device readiness timeout (default 240000)
|
|
72
|
+
--topo PATH write the scenario's topology blueprint to a .topo file
|
|
73
|
+
--json machine-readable output
|
|
74
|
+
-h, --help show this help
|
|
75
|
+
|
|
76
|
+
${C.bold('EXAMPLES')}
|
|
77
|
+
ensp up lab.topo
|
|
78
|
+
ensp apply ./configs --topology lab.topo --save
|
|
79
|
+
ensp status --topology lab.topo
|
|
80
|
+
ensp probe
|
|
81
|
+
ensp preset --search ospf
|
|
82
|
+
ensp preset ospf-single
|
|
83
|
+
ensp preset campus-network --topo campus.topo
|
|
84
|
+
ensp mcp
|
|
85
|
+
|
|
86
|
+
${C.bold('NOTES')}
|
|
87
|
+
· eNSP must be installed and runnable; it requires administrator rights.
|
|
88
|
+
· Starting devices briefly focuses the eNSP window (it has to be clicked),
|
|
89
|
+
then the window is minimised again.
|
|
90
|
+
· Readiness is verified per device by polling its console, never by sleeping.
|
|
91
|
+
`
|
|
92
|
+
|
|
93
|
+
/** Parse argv into { verb, positional, flags }. */
|
|
94
|
+
function parseArgs(argv) {
|
|
95
|
+
const positional = []
|
|
96
|
+
const flags = {}
|
|
97
|
+
for (let i = 0; i < argv.length; i++) {
|
|
98
|
+
const a = argv[i]
|
|
99
|
+
if (a === '-h' || a === '--help') {
|
|
100
|
+
flags.help = true
|
|
101
|
+
} else if (a.startsWith('--')) {
|
|
102
|
+
const key = a.slice(2)
|
|
103
|
+
const next = argv[i + 1]
|
|
104
|
+
if (next && !next.startsWith('--')) {
|
|
105
|
+
flags[key] = next
|
|
106
|
+
i++
|
|
107
|
+
} else {
|
|
108
|
+
flags[key] = true
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
positional.push(a)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { verb: positional.shift() ?? null, positional, flags }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function exists(file) {
|
|
118
|
+
try {
|
|
119
|
+
await access(file, constants.R_OK)
|
|
120
|
+
return true
|
|
121
|
+
} catch {
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Resolve the eNSP window, launching the client if needed. */
|
|
127
|
+
async function ensureWindow(topologyPath) {
|
|
128
|
+
const pid = enspPid()
|
|
129
|
+
if (pid) {
|
|
130
|
+
const hwnd = locateWindow(pid)
|
|
131
|
+
if (hwnd) return { hwnd, launched: false }
|
|
132
|
+
}
|
|
133
|
+
const launched = await launchEnsp({ topology: topologyPath })
|
|
134
|
+
return { hwnd: launched.hwnd, launched: true }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Pretty console line for a device readiness event. */
|
|
138
|
+
function deviceLine(event) {
|
|
139
|
+
const name = event.name.padEnd(12)
|
|
140
|
+
if (event.ready) {
|
|
141
|
+
return ` ${C.green('✓')} ${name} ${C.dim(`console ${event.port} → ${event.prompt}`)} ${C.dim(`${(event.waitedMs / 1000).toFixed(1)}s`)}`
|
|
142
|
+
}
|
|
143
|
+
return ` ${C.red('✗')} ${name} ${C.dim(`console ${event.port} never answered (${(event.waitedMs / 1000).toFixed(1)}s)`)}`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --------------------------------------------------------------------------- up
|
|
147
|
+
async function cmdUp({ positional, flags }) {
|
|
148
|
+
const topoPath = positional[0]
|
|
149
|
+
if (!topoPath) {
|
|
150
|
+
console.error(C.red('error:') + ' missing topology path')
|
|
151
|
+
return 2
|
|
152
|
+
}
|
|
153
|
+
if (!(await exists(topoPath))) {
|
|
154
|
+
console.error(C.red('error:') + ` no such file: ${topoPath}`)
|
|
155
|
+
return 2
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const topo = await readTopology(path.resolve(topoPath))
|
|
159
|
+
const info = summarise(topo)
|
|
160
|
+
console.log(`${C.bold('topology')} ${topoPath}`)
|
|
161
|
+
console.log(
|
|
162
|
+
` ${info.devices} devices, ${info.links} links ` +
|
|
163
|
+
C.dim(`(${Object.entries(info.byModel).map(([m, n]) => `${m}×${n}`).join(', ')})`),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
const { hwnd, launched } = await ensureWindow(path.resolve(topoPath))
|
|
167
|
+
console.log(`${C.bold('window')} hwnd=${hwnd}${launched ? C.dim(' (launched)') : C.dim(' (reused)')}`)
|
|
168
|
+
|
|
169
|
+
if (flags.stop) {
|
|
170
|
+
const r = stopAllDevices(hwnd)
|
|
171
|
+
console.log(`${C.bold('stop')} ${r.anchors?.method ?? 'default'} anchor ${JSON.stringify(r.anchors?.stop ?? DEFAULT_ANCHORS.stop)}`)
|
|
172
|
+
console.log(` ${C.yellow('all devices asked to shut down')}`)
|
|
173
|
+
return 0
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const timeout = Number(flags.timeout ?? 240000)
|
|
177
|
+
console.log(`${C.bold('starting devices')} ${C.dim(`(per-device timeout ${timeout / 1000}s)`)}`)
|
|
178
|
+
|
|
179
|
+
const result = await startAllDevices({
|
|
180
|
+
devices: topo.devices,
|
|
181
|
+
hwnd,
|
|
182
|
+
perDeviceTimeoutMs: timeout,
|
|
183
|
+
minimise: !flags.keepWindow,
|
|
184
|
+
onProgress: (e) => {
|
|
185
|
+
if (e.phase === 'calibrated') {
|
|
186
|
+
console.log(
|
|
187
|
+
` ${C.dim('toolbar anchors')} ${e.method === 'measured' ? C.green('measured') : C.yellow('default')} ` +
|
|
188
|
+
C.dim(JSON.stringify(e.detail.pickedStart ?? e.detail)),
|
|
189
|
+
)
|
|
190
|
+
} else if (e.phase === 'device') {
|
|
191
|
+
console.log(deviceLine(e))
|
|
192
|
+
} else if (e.phase === 'minimised') {
|
|
193
|
+
console.log(` ${C.dim('window minimised — desktop handed back')}`)
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
console.log(
|
|
199
|
+
`\n${result.allReady ? C.green('all devices ready') : C.yellow(`${result.ready}/${result.total} devices ready`)}` +
|
|
200
|
+
C.dim(` (${result.newPorts.length} new console ports)`),
|
|
201
|
+
)
|
|
202
|
+
return result.allReady ? 0 : 1
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ------------------------------------------------------------------------ apply
|
|
206
|
+
async function cmdApply({ positional, flags }) {
|
|
207
|
+
const dir = positional[0]
|
|
208
|
+
const topoPath = flags.topology
|
|
209
|
+
if (!dir || !topoPath) {
|
|
210
|
+
console.error(C.red('error:') + ' usage: ensp apply <config-dir> --topology <topology.topo>')
|
|
211
|
+
return 2
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const topo = await readTopology(path.resolve(topoPath))
|
|
215
|
+
const consoles = new Map(topo.devices.filter((d) => d.comPort).map((d) => [d.name, d.comPort]))
|
|
216
|
+
|
|
217
|
+
const entries = (await import('node:fs/promises')).readdir
|
|
218
|
+
const files = await (await import('node:fs/promises')).readdir(dir)
|
|
219
|
+
const cfgs = files.filter((f) => f.endsWith('.cfg') || f.endsWith('.txt'))
|
|
220
|
+
if (!cfgs.length) {
|
|
221
|
+
console.error(C.red('error:') + ` no .cfg/.txt files in ${dir}`)
|
|
222
|
+
return 2
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let failures = 0
|
|
226
|
+
for (const file of cfgs) {
|
|
227
|
+
const device = path.basename(file).replace(/\.(cfg|txt)$/, '')
|
|
228
|
+
if (flags.device && flags.device !== device) continue
|
|
229
|
+
const port = consoles.get(device)
|
|
230
|
+
if (!port) {
|
|
231
|
+
console.log(` ${C.yellow('skip')} ${device} ${C.dim('(no console port in topology)')}`)
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Confirm the console is actually alive before pushing.
|
|
236
|
+
const probe = await waitForConsole({ port, timeoutMs: 8000 })
|
|
237
|
+
if (!probe.ready) {
|
|
238
|
+
console.log(` ${C.red('✗')} ${device} ${C.dim('console not answering, skipped')}`)
|
|
239
|
+
failures++
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const script = await readFile(path.join(dir, file), 'utf8')
|
|
244
|
+
process.stdout.write(` ${C.dim('…')} ${device.padEnd(12)}`)
|
|
245
|
+
const r = await pushConfig({ device, port, script, save: Boolean(flags.save) })
|
|
246
|
+
if (r.ok) {
|
|
247
|
+
console.log(`\r ${C.green('✓')} ${device.padEnd(12)} ${r.applied}/${r.total} commands${r.saved ? ', saved' : ''} ${C.dim(`${(r.durationMs / 1000).toFixed(1)}s`)}`)
|
|
248
|
+
} else {
|
|
249
|
+
failures++
|
|
250
|
+
console.log(`\r ${C.red('✗')} ${device.padEnd(12)} stopped at line ${r.failedAt?.line}: ${r.failedAt?.error}`)
|
|
251
|
+
console.log(C.dim(` ${r.failedAt?.command ?? ''}`))
|
|
252
|
+
if (r.failedAt?.output) {
|
|
253
|
+
console.log(C.dim(r.failedAt.output.trim().split('\n').slice(-6).map((l) => ' ' + l).join('\n')))
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return failures ? 1 : 0
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ----------------------------------------------------------------------- status
|
|
262
|
+
async function cmdStatus({ flags }) {
|
|
263
|
+
const pid = enspPid()
|
|
264
|
+
if (!pid) {
|
|
265
|
+
console.log(`${C.bold('eNSP')} ${C.red('not running')}`)
|
|
266
|
+
return 1
|
|
267
|
+
}
|
|
268
|
+
const hwnd = locateWindow(pid)
|
|
269
|
+
console.log(`${C.bold('eNSP')} pid=${pid} hwnd=${hwnd ?? C.red('no window')}`)
|
|
270
|
+
|
|
271
|
+
const anchors = hwnd ? calibrateAnchors(hwnd) : { ...DEFAULT_ANCHORS, method: 'default', detail: {} }
|
|
272
|
+
console.log(
|
|
273
|
+
`${C.bold('anchors')} ${anchors.method === 'measured' ? C.green('measured') : C.yellow('default')} ` +
|
|
274
|
+
`start=${JSON.stringify(anchors.start)} stop=${JSON.stringify(anchors.stop)}`,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if (!flags.topology) {
|
|
278
|
+
console.log(C.dim(' (pass --topology to verify each console)'))
|
|
279
|
+
return 0
|
|
280
|
+
}
|
|
281
|
+
const topo = await readTopology(path.resolve(flags.topology))
|
|
282
|
+
const targets = topo.devices.filter((d) => d.comPort)
|
|
283
|
+
console.log(`${C.bold('devices')} ${targets.length}`)
|
|
284
|
+
|
|
285
|
+
let ready = 0
|
|
286
|
+
await Promise.all(
|
|
287
|
+
targets.map(async (d) => {
|
|
288
|
+
const r = await waitForConsole({ port: d.comPort, timeoutMs: 4000 })
|
|
289
|
+
if (r.ready) {
|
|
290
|
+
ready++
|
|
291
|
+
console.log(` ${C.green('✓')} ${d.name.padEnd(12)} :${d.comPort} ${C.dim(r.prompt ?? '')}`)
|
|
292
|
+
} else {
|
|
293
|
+
console.log(` ${C.red('✗')} ${d.name.padEnd(12)} :${d.comPort} ${C.dim('not answering')}`)
|
|
294
|
+
}
|
|
295
|
+
}),
|
|
296
|
+
)
|
|
297
|
+
console.log(`\n${ready}/${targets.length} consoles ready`)
|
|
298
|
+
return ready === targets.length ? 0 : 1
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ----------------------------------------------------------------------- preset
|
|
302
|
+
async function cmdPreset({ positional, flags }) {
|
|
303
|
+
const id = positional[0]
|
|
304
|
+
|
|
305
|
+
if (flags.search) {
|
|
306
|
+
const hits = searchScenarios(String(flags.search))
|
|
307
|
+
if (flags.json) {
|
|
308
|
+
console.log(JSON.stringify(hits, null, 2))
|
|
309
|
+
return hits.length ? 0 : 1
|
|
310
|
+
}
|
|
311
|
+
if (!hits.length) {
|
|
312
|
+
console.log(C.yellow('no scenario matched') + ` "${flags.search}"`)
|
|
313
|
+
return 1
|
|
314
|
+
}
|
|
315
|
+
for (const h of hits) {
|
|
316
|
+
console.log(`${C.bold(h.id.padEnd(24))} ${h.title} ${C.dim(`[${h.level}]`)}`)
|
|
317
|
+
console.log(C.dim(` ${h.goal}`))
|
|
318
|
+
}
|
|
319
|
+
return 0
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!id) {
|
|
323
|
+
const groups = byLevel()
|
|
324
|
+
const labels = { basic: '基础', intermediate: '进阶', advanced: '高级' }
|
|
325
|
+
for (const [level, ids] of Object.entries(groups)) {
|
|
326
|
+
if (!ids.length) continue
|
|
327
|
+
console.log(`${C.bold(labels[level])} ${C.dim(`(${ids.length})`)}`)
|
|
328
|
+
for (const sid of ids) {
|
|
329
|
+
console.log(` ${sid.padEnd(24)} ${SCENARIOS[sid].title}`)
|
|
330
|
+
}
|
|
331
|
+
console.log()
|
|
332
|
+
}
|
|
333
|
+
console.log(C.dim(`${SCENARIO_IDS.length} 个预设 · ensp preset <id> 查看详情 · --search <kw> 搜索`))
|
|
334
|
+
return 0
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (!SCENARIOS[id]) {
|
|
338
|
+
console.error(C.red('error:') + ` unknown scenario "${id}" · ensp preset --search <kw>`)
|
|
339
|
+
return 2
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
if (flags.topo) {
|
|
344
|
+
if (!hasLab(id)) {
|
|
345
|
+
console.error(
|
|
346
|
+
C.red('error:') +
|
|
347
|
+
` scenario "${id}" has no topology blueprint yet; ${LAB_IDS.length} of ${SCENARIO_IDS.length} have one`,
|
|
348
|
+
)
|
|
349
|
+
return 2
|
|
350
|
+
}
|
|
351
|
+
const topo = buildScenarioTopology(id)
|
|
352
|
+
const target = path.resolve(String(flags.topo))
|
|
353
|
+
await writeTopology(target, topo)
|
|
354
|
+
console.log(`${C.green('wrote')} ${target}`)
|
|
355
|
+
for (const line of labDevices(id)) console.log(` ${line}`)
|
|
356
|
+
for (const row of labAddressing(id)) {
|
|
357
|
+
console.log(` ${row.device.padEnd(10)} ${row.port.padEnd(22)} ${row.ip.padEnd(20)} ${C.dim(row.note)}`)
|
|
358
|
+
}
|
|
359
|
+
console.log(C.dim(` ${topo.devices.length} 台设备 · ${topo.links.length} 条链路 · ensp up ${target}`))
|
|
360
|
+
|
|
361
|
+
// Warn now rather than let `ensp up` fail later: a missing device image is
|
|
362
|
+
// invisible in the .topo file and eNSP quits without a message.
|
|
363
|
+
const support = checkTopologySupport(topo)
|
|
364
|
+
if (!support.ok) {
|
|
365
|
+
console.error('')
|
|
366
|
+
for (const b of support.blocked) {
|
|
367
|
+
console.error(`${C.yellow('warning:')} ${b.name} (${b.model}) ${b.reason}`)
|
|
368
|
+
}
|
|
369
|
+
console.error(
|
|
370
|
+
C.dim(' 这台机器装不上该机型时,eNSP 会打开拓扑后自行退出,ensp up 会直接报错。'),
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
return 0
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const text = renderScenario(id)
|
|
377
|
+
console.log(flags.json ? JSON.stringify(SCENARIOS[id], null, 2) : text)
|
|
378
|
+
if (hasLab(id)) {
|
|
379
|
+
console.log(C.dim(`\n可生成拓扑: ensp preset ${id} --topo ${id}.topo`))
|
|
380
|
+
}
|
|
381
|
+
return 0
|
|
382
|
+
} catch (error) {
|
|
383
|
+
console.error(C.red('error:') + ' ' + error.message)
|
|
384
|
+
return 2
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// -------------------------------------------------------------------------- mcp
|
|
389
|
+
async function cmdMcp() {
|
|
390
|
+
const { serve } = await import('../mcp/server.mjs')
|
|
391
|
+
serve()
|
|
392
|
+
return null // keep the process alive
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ------------------------------------------------------------------------ probe
|
|
396
|
+
async function cmdProbe({ flags }) {
|
|
397
|
+
const pid = enspPid()
|
|
398
|
+
if (!pid) {
|
|
399
|
+
console.error(C.red('error:') + ' eNSP is not running')
|
|
400
|
+
return 1
|
|
401
|
+
}
|
|
402
|
+
const hwnd = locateWindow(pid)
|
|
403
|
+
if (!hwnd) {
|
|
404
|
+
console.error(C.red('error:') + ' eNSP window not found')
|
|
405
|
+
return 1
|
|
406
|
+
}
|
|
407
|
+
const r = inspectAnchors(hwnd)
|
|
408
|
+
if (flags.json) {
|
|
409
|
+
console.log(JSON.stringify(r, null, 2))
|
|
410
|
+
return r.resolved?.method === 'measured' ? 0 : 1
|
|
411
|
+
}
|
|
412
|
+
console.log(`${C.bold('window')} ${r.window ?? '?'} (physical pixels)`)
|
|
413
|
+
console.log(`${C.bold('green clusters')} ${r.green.length}`)
|
|
414
|
+
for (const g of r.green) console.log(C.dim(` x=${g.x} y=${g.y} ${g.w}x${g.h}`))
|
|
415
|
+
console.log(`${C.bold('red clusters')} ${r.red.length}`)
|
|
416
|
+
for (const g of r.red) console.log(C.dim(` x=${g.x} y=${g.y} ${g.w}x${g.h}`))
|
|
417
|
+
if (r.resolved) {
|
|
418
|
+
console.log(
|
|
419
|
+
`\n${C.bold('resolved')} ${r.resolved.method === 'measured' ? C.green('measured') : C.yellow('default')}`,
|
|
420
|
+
)
|
|
421
|
+
console.log(` start ${JSON.stringify(r.resolved.start)}`)
|
|
422
|
+
console.log(` stop ${JSON.stringify(r.resolved.stop)}`)
|
|
423
|
+
}
|
|
424
|
+
return r.resolved?.method === 'measured' ? 0 : 1
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ------------------------------------------------------------------------- main
|
|
428
|
+
async function main() {
|
|
429
|
+
const { verb, positional, flags } = parseArgs(process.argv.slice(2))
|
|
430
|
+
if (!verb || flags.help) {
|
|
431
|
+
console.log(HELP)
|
|
432
|
+
return flags.help && !verb ? 0 : 2
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
switch (verb) {
|
|
436
|
+
case 'up':
|
|
437
|
+
return cmdUp({ positional, flags })
|
|
438
|
+
case 'apply':
|
|
439
|
+
return cmdApply({ positional, flags })
|
|
440
|
+
case 'status':
|
|
441
|
+
return cmdStatus({ positional, flags })
|
|
442
|
+
case 'probe':
|
|
443
|
+
return cmdProbe({ positional, flags })
|
|
444
|
+
case 'stop':
|
|
445
|
+
return cmdUp({ positional, flags: { ...flags, stop: true } })
|
|
446
|
+
case 'preset':
|
|
447
|
+
return cmdPreset({ positional, flags })
|
|
448
|
+
case 'mcp':
|
|
449
|
+
return cmdMcp()
|
|
450
|
+
default:
|
|
451
|
+
console.error(C.red('error:') + ` unknown command "${verb}"\n`)
|
|
452
|
+
console.log(HELP)
|
|
453
|
+
return 2
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
main()
|
|
458
|
+
.then((code) => process.exit(code ?? 0))
|
|
459
|
+
.catch((error) => {
|
|
460
|
+
console.error(C.red('error:') + ' ' + (error?.stack ?? error))
|
|
461
|
+
process.exit(1)
|
|
462
|
+
})
|