dsh-ensp 0.1.3 → 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/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
- const C = {
38
- dim: (s) => `\u001b[2m${s}\u001b[0m`,
39
- green: (s) => `\u001b[32m${s}\u001b[0m`,
40
- red: (s) => `\u001b[31m${s}\u001b[0m`,
41
- yellow: (s) => `\u001b[33m${s}\u001b[0m`,
42
- bold: (s) => `\u001b[1m${s}\u001b[0m`,
43
- }
44
-
45
- const HELP = `
46
- ${C.bold('ensp')} — automate Huawei eNSP labs
47
-
48
- ${C.bold('USAGE')}
49
- ensp up <topology.topo> [--stop] [--keep-window]
50
- ensp apply <config-dir> --topology <topology.topo> [--save] [--device NAME]
51
- ensp status [--topology <topology.topo>]
52
- ensp probe [--json]
53
- ensp stop [--topology <topology.topo>]
54
- ensp preset [<id>] [--search <keyword>] [--json]
55
- ensp mcp (start the MCP server on stdio)
56
-
57
- ${C.bold('OPTIONS')}
58
- --stop stop all devices instead of starting them
59
- --keep-window leave the eNSP window visible (default: minimise after clicking)
60
- --save run "save" on each device after its configuration
61
- --device NAME apply only to one device
62
- --timeout MS per-device readiness timeout (default 240000)
63
- --json machine-readable output
64
- -h, --help show this help
65
-
66
- ${C.bold('EXAMPLES')}
67
- ensp up lab.topo
68
- ensp apply ./configs --topology lab.topo --save
69
- ensp status --topology lab.topo
70
- ensp probe
71
- ensp preset --search ospf
72
- ensp preset ospf-single
73
- ensp mcp
74
-
75
- ${C.bold('NOTES')}
76
- · eNSP must be installed and runnable; it requires administrator rights.
77
- · Starting devices briefly focuses the eNSP window (it has to be clicked),
78
- then the window is minimised again.
79
- · Readiness is verified per device by polling its console, never by sleeping.
80
- `
81
-
82
- /** Parse argv into { verb, positional, flags }. */
83
- function parseArgs(argv) {
84
- const positional = []
85
- const flags = {}
86
- for (let i = 0; i < argv.length; i++) {
87
- const a = argv[i]
88
- if (a === '-h' || a === '--help') {
89
- flags.help = true
90
- } else if (a.startsWith('--')) {
91
- const key = a.slice(2)
92
- const next = argv[i + 1]
93
- if (next && !next.startsWith('--')) {
94
- flags[key] = next
95
- i++
96
- } else {
97
- flags[key] = true
98
- }
99
- } else {
100
- positional.push(a)
101
- }
102
- }
103
- return { verb: positional.shift() ?? null, positional, flags }
104
- }
105
-
106
- async function exists(file) {
107
- try {
108
- await access(file, constants.R_OK)
109
- return true
110
- } catch {
111
- return false
112
- }
113
- }
114
-
115
- /** Resolve the eNSP window, launching the client if needed. */
116
- async function ensureWindow(topologyPath) {
117
- const pid = enspPid()
118
- if (pid) {
119
- const hwnd = locateWindow(pid)
120
- if (hwnd) return { hwnd, launched: false }
121
- }
122
- const launched = await launchEnsp({ topology: topologyPath })
123
- return { hwnd: launched.hwnd, launched: true }
124
- }
125
-
126
- /** Pretty console line for a device readiness event. */
127
- function deviceLine(event) {
128
- const name = event.name.padEnd(12)
129
- if (event.ready) {
130
- return ` ${C.green('✓')} ${name} ${C.dim(`console ${event.port} → ${event.prompt}`)} ${C.dim(`${(event.waitedMs / 1000).toFixed(1)}s`)}`
131
- }
132
- return ` ${C.red('✗')} ${name} ${C.dim(`console ${event.port} never answered (${(event.waitedMs / 1000).toFixed(1)}s)`)}`
133
- }
134
-
135
- // --------------------------------------------------------------------------- up
136
- async function cmdUp({ positional, flags }) {
137
- const topoPath = positional[0]
138
- if (!topoPath) {
139
- console.error(C.red('error:') + ' missing topology path')
140
- return 2
141
- }
142
- if (!(await exists(topoPath))) {
143
- console.error(C.red('error:') + ` no such file: ${topoPath}`)
144
- return 2
145
- }
146
-
147
- const topo = await readTopology(path.resolve(topoPath))
148
- const info = summarise(topo)
149
- console.log(`${C.bold('topology')} ${topoPath}`)
150
- console.log(
151
- ` ${info.devices} devices, ${info.links} links ` +
152
- C.dim(`(${Object.entries(info.byModel).map(([m, n]) => `${m}×${n}`).join(', ')})`),
153
- )
154
-
155
- const { hwnd, launched } = await ensureWindow(path.resolve(topoPath))
156
- console.log(`${C.bold('window')} hwnd=${hwnd}${launched ? C.dim(' (launched)') : C.dim(' (reused)')}`)
157
-
158
- if (flags.stop) {
159
- const r = stopAllDevices(hwnd)
160
- console.log(`${C.bold('stop')} ${r.anchors?.method ?? 'default'} anchor ${JSON.stringify(r.anchors?.stop ?? DEFAULT_ANCHORS.stop)}`)
161
- console.log(` ${C.yellow('all devices asked to shut down')}`)
162
- return 0
163
- }
164
-
165
- const timeout = Number(flags.timeout ?? 240000)
166
- console.log(`${C.bold('starting devices')} ${C.dim(`(per-device timeout ${timeout / 1000}s)`)}`)
167
-
168
- const result = await startAllDevices({
169
- devices: topo.devices,
170
- hwnd,
171
- perDeviceTimeoutMs: timeout,
172
- minimise: !flags.keepWindow,
173
- onProgress: (e) => {
174
- if (e.phase === 'calibrated') {
175
- console.log(
176
- ` ${C.dim('toolbar anchors')} ${e.method === 'measured' ? C.green('measured') : C.yellow('default')} ` +
177
- C.dim(JSON.stringify(e.detail.pickedStart ?? e.detail)),
178
- )
179
- } else if (e.phase === 'device') {
180
- console.log(deviceLine(e))
181
- } else if (e.phase === 'minimised') {
182
- console.log(` ${C.dim('window minimised — desktop handed back')}`)
183
- }
184
- },
185
- })
186
-
187
- console.log(
188
- `\n${result.allReady ? C.green('all devices ready') : C.yellow(`${result.ready}/${result.total} devices ready`)}` +
189
- C.dim(` (${result.newPorts.length} new console ports)`),
190
- )
191
- return result.allReady ? 0 : 1
192
- }
193
-
194
- // ------------------------------------------------------------------------ apply
195
- async function cmdApply({ positional, flags }) {
196
- const dir = positional[0]
197
- const topoPath = flags.topology
198
- if (!dir || !topoPath) {
199
- console.error(C.red('error:') + ' usage: ensp apply <config-dir> --topology <topology.topo>')
200
- return 2
201
- }
202
-
203
- const topo = await readTopology(path.resolve(topoPath))
204
- const consoles = new Map(topo.devices.filter((d) => d.comPort).map((d) => [d.name, d.comPort]))
205
-
206
- const entries = (await import('node:fs/promises')).readdir
207
- const files = await (await import('node:fs/promises')).readdir(dir)
208
- const cfgs = files.filter((f) => f.endsWith('.cfg') || f.endsWith('.txt'))
209
- if (!cfgs.length) {
210
- console.error(C.red('error:') + ` no .cfg/.txt files in ${dir}`)
211
- return 2
212
- }
213
-
214
- let failures = 0
215
- for (const file of cfgs) {
216
- const device = path.basename(file).replace(/\.(cfg|txt)$/, '')
217
- if (flags.device && flags.device !== device) continue
218
- const port = consoles.get(device)
219
- if (!port) {
220
- console.log(` ${C.yellow('skip')} ${device} ${C.dim('(no console port in topology)')}`)
221
- continue
222
- }
223
-
224
- // Confirm the console is actually alive before pushing.
225
- const probe = await waitForConsole({ port, timeoutMs: 8000 })
226
- if (!probe.ready) {
227
- console.log(` ${C.red('✗')} ${device} ${C.dim('console not answering, skipped')}`)
228
- failures++
229
- continue
230
- }
231
-
232
- const script = await readFile(path.join(dir, file), 'utf8')
233
- process.stdout.write(` ${C.dim('…')} ${device.padEnd(12)}`)
234
- const r = await pushConfig({ device, port, script, save: Boolean(flags.save) })
235
- if (r.ok) {
236
- console.log(`\r ${C.green('✓')} ${device.padEnd(12)} ${r.applied}/${r.total} commands${r.saved ? ', saved' : ''} ${C.dim(`${(r.durationMs / 1000).toFixed(1)}s`)}`)
237
- } else {
238
- failures++
239
- console.log(`\r ${C.red('✗')} ${device.padEnd(12)} stopped at line ${r.failedAt?.line}: ${r.failedAt?.error}`)
240
- console.log(C.dim(` ${r.failedAt?.command ?? ''}`))
241
- if (r.failedAt?.output) {
242
- console.log(C.dim(r.failedAt.output.trim().split('\n').slice(-6).map((l) => ' ' + l).join('\n')))
243
- }
244
- }
245
- }
246
-
247
- return failures ? 1 : 0
248
- }
249
-
250
- // ----------------------------------------------------------------------- status
251
- async function cmdStatus({ flags }) {
252
- const pid = enspPid()
253
- if (!pid) {
254
- console.log(`${C.bold('eNSP')} ${C.red('not running')}`)
255
- return 1
256
- }
257
- const hwnd = locateWindow(pid)
258
- console.log(`${C.bold('eNSP')} pid=${pid} hwnd=${hwnd ?? C.red('no window')}`)
259
-
260
- const anchors = hwnd ? calibrateAnchors(hwnd) : { ...DEFAULT_ANCHORS, method: 'default', detail: {} }
261
- console.log(
262
- `${C.bold('anchors')} ${anchors.method === 'measured' ? C.green('measured') : C.yellow('default')} ` +
263
- `start=${JSON.stringify(anchors.start)} stop=${JSON.stringify(anchors.stop)}`,
264
- )
265
-
266
- if (!flags.topology) {
267
- console.log(C.dim(' (pass --topology to verify each console)'))
268
- return 0
269
- }
270
- const topo = await readTopology(path.resolve(flags.topology))
271
- const targets = topo.devices.filter((d) => d.comPort)
272
- console.log(`${C.bold('devices')} ${targets.length}`)
273
-
274
- let ready = 0
275
- await Promise.all(
276
- targets.map(async (d) => {
277
- const r = await waitForConsole({ port: d.comPort, timeoutMs: 4000 })
278
- if (r.ready) {
279
- ready++
280
- console.log(` ${C.green('✓')} ${d.name.padEnd(12)} :${d.comPort} ${C.dim(r.prompt ?? '')}`)
281
- } else {
282
- console.log(` ${C.red('✗')} ${d.name.padEnd(12)} :${d.comPort} ${C.dim('not answering')}`)
283
- }
284
- }),
285
- )
286
- console.log(`\n${ready}/${targets.length} consoles ready`)
287
- return ready === targets.length ? 0 : 1
288
- }
289
-
290
- // ----------------------------------------------------------------------- preset
291
- async function cmdPreset({ positional, flags }) {
292
- const id = positional[0]
293
-
294
- if (flags.search) {
295
- const hits = searchScenarios(String(flags.search))
296
- if (flags.json) {
297
- console.log(JSON.stringify(hits, null, 2))
298
- return hits.length ? 0 : 1
299
- }
300
- if (!hits.length) {
301
- console.log(C.yellow('no scenario matched') + ` "${flags.search}"`)
302
- return 1
303
- }
304
- for (const h of hits) {
305
- console.log(`${C.bold(h.id.padEnd(24))} ${h.title} ${C.dim(`[${h.level}]`)}`)
306
- console.log(C.dim(` ${h.goal}`))
307
- }
308
- return 0
309
- }
310
-
311
- if (!id) {
312
- const groups = byLevel()
313
- const labels = { basic: '基础', intermediate: '进阶', advanced: '高级' }
314
- for (const [level, ids] of Object.entries(groups)) {
315
- if (!ids.length) continue
316
- console.log(`${C.bold(labels[level])} ${C.dim(`(${ids.length})`)}`)
317
- for (const sid of ids) {
318
- console.log(` ${sid.padEnd(24)} ${SCENARIOS[sid].title}`)
319
- }
320
- console.log()
321
- }
322
- console.log(C.dim(`${SCENARIO_IDS.length} 个预设 · ensp preset <id> 查看详情 · --search <kw> 搜索`))
323
- return 0
324
- }
325
-
326
- try {
327
- const text = renderScenario(id)
328
- console.log(flags.json ? JSON.stringify(SCENARIOS[id], null, 2) : text)
329
- return 0
330
- } catch (error) {
331
- console.error(C.red('error:') + ' ' + error.message)
332
- return 2
333
- }
334
- }
335
-
336
- // -------------------------------------------------------------------------- mcp
337
- async function cmdMcp() {
338
- const { serve } = await import('../mcp/server.mjs')
339
- serve()
340
- return null // keep the process alive
341
- }
342
-
343
- // ------------------------------------------------------------------------ probe
344
- async function cmdProbe({ flags }) {
345
- const pid = enspPid()
346
- if (!pid) {
347
- console.error(C.red('error:') + ' eNSP is not running')
348
- return 1
349
- }
350
- const hwnd = locateWindow(pid)
351
- if (!hwnd) {
352
- console.error(C.red('error:') + ' eNSP window not found')
353
- return 1
354
- }
355
- const r = inspectAnchors(hwnd)
356
- if (flags.json) {
357
- console.log(JSON.stringify(r, null, 2))
358
- return r.resolved?.method === 'measured' ? 0 : 1
359
- }
360
- console.log(`${C.bold('window')} ${r.window ?? '?'} (physical pixels)`)
361
- console.log(`${C.bold('green clusters')} ${r.green.length}`)
362
- for (const g of r.green) console.log(C.dim(` x=${g.x} y=${g.y} ${g.w}x${g.h}`))
363
- console.log(`${C.bold('red clusters')} ${r.red.length}`)
364
- for (const g of r.red) console.log(C.dim(` x=${g.x} y=${g.y} ${g.w}x${g.h}`))
365
- if (r.resolved) {
366
- console.log(
367
- `\n${C.bold('resolved')} ${r.resolved.method === 'measured' ? C.green('measured') : C.yellow('default')}`,
368
- )
369
- console.log(` start ${JSON.stringify(r.resolved.start)}`)
370
- console.log(` stop ${JSON.stringify(r.resolved.stop)}`)
371
- }
372
- return r.resolved?.method === 'measured' ? 0 : 1
373
- }
374
-
375
- // ------------------------------------------------------------------------- main
376
- async function main() {
377
- const { verb, positional, flags } = parseArgs(process.argv.slice(2))
378
- if (!verb || flags.help) {
379
- console.log(HELP)
380
- return flags.help && !verb ? 0 : 2
381
- }
382
-
383
- switch (verb) {
384
- case 'up':
385
- return cmdUp({ positional, flags })
386
- case 'apply':
387
- return cmdApply({ positional, flags })
388
- case 'status':
389
- return cmdStatus({ positional, flags })
390
- case 'probe':
391
- return cmdProbe({ positional, flags })
392
- case 'stop':
393
- return cmdUp({ positional, flags: { ...flags, stop: true } })
394
- case 'preset':
395
- return cmdPreset({ positional, flags })
396
- case 'mcp':
397
- return cmdMcp()
398
- default:
399
- console.error(C.red('error:') + ` unknown command "${verb}"\n`)
400
- console.log(HELP)
401
- return 2
402
- }
403
- }
404
-
405
- main()
406
- .then((code) => process.exit(code ?? 0))
407
- .catch((error) => {
408
- console.error(C.red('error:') + ' ' + (error?.stack ?? error))
409
- process.exit(1)
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
+ })