dsh-ensp 0.3.3 → 0.4.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.
@@ -35,6 +35,7 @@ import path from 'node:path'
35
35
 
36
36
  import { calibrateAnchors, DEFAULT_ANCHORS } from './calibrate.mjs'
37
37
  import { checkTopologySupport, describeBlocked, rootFromExe } from './compat.mjs'
38
+ import { resolveEnspExe } from './paths.mjs'
38
39
  import { readTopology } from './topology.mjs'
39
40
 
40
41
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
@@ -301,7 +302,7 @@ export function enspPids() {
301
302
  * @returns {Promise<{pid: number, hwnd: string}>}
302
303
  */
303
304
  export async function launchEnsp({ topology, exspPath, readyTimeoutMs = 120000 } = {}) {
304
- const exe = exspPath ?? process.env.DSH_ENSP_EXE ?? 'E:\\Huawei\\eNSP\\eNSP_Client.exe'
305
+ const exe = resolveEnspExe(exspPath).path
305
306
  const target = topology ? path.resolve(topology) : null
306
307
 
307
308
  // Reject topologies whose devices have no installed image *before* starting the
@@ -469,7 +470,7 @@ $p = Start-Process -FilePath '${exe.replace(/'/g, "''")}'${topology ? ` -Argumen
469
470
  * @param {string} topology
470
471
  */
471
472
  function reopenTopology(topology) {
472
- const exe = process.env.DSH_ENSP_EXE ?? 'E:\\Huawei\\eNSP\\eNSP_Client.exe'
473
+ const exe = resolveEnspExe().path
473
474
  startDetached(exe, topology)
474
475
  }
475
476
 
@@ -591,15 +592,87 @@ Start-Sleep -Milliseconds 250
591
592
  return r.ok && r.stdout.split(/\s+/).pop() === 'True'
592
593
  }
593
594
 
595
+ /**
596
+ * The window handle that currently holds the foreground, or null.
597
+ *
598
+ * Captured before eNSP is raised so it can be handed back afterwards.
599
+ *
600
+ * @returns {string|null}
601
+ */
602
+ export function foregroundWindow() {
603
+ const r = runPowerShell(
604
+ `$h = [EnspWin]::GetForegroundWindow()
605
+ ([int64]$h).ToString()`,
606
+ { timeoutMs: 15000 },
607
+ )
608
+ const raw = (r.stdout || '').trim().split(/\s+/).pop()
609
+ if (!raw || raw === '0') return null
610
+ return raw
611
+ }
612
+
613
+ /**
614
+ * Put the foreground back on a previously active window.
615
+ *
616
+ * Used after a click that had to raise eNSP: leaving eNSP in front is a
617
+ * side effect the caller did not ask for. The window state is not touched, so a
618
+ * visible topology stays visible.
619
+ *
620
+ * @param {string|null} hwnd
621
+ * @returns {boolean} whether the foreground moved back
622
+ */
623
+ export function restoreForeground(hwnd) {
624
+ if (!hwnd) return false
625
+ const r = runPowerShell(
626
+ `$prev = [IntPtr]::new([int64]${hwnd})
627
+ $ok = [EnspWin]::SetForegroundWindow($prev)
628
+ Start-Sleep -Milliseconds 120
629
+ ($ok -and ([EnspWin]::GetForegroundWindow() -eq $prev)).ToString()`,
630
+ { timeoutMs: 15000 },
631
+ )
632
+ return r.ok && (r.stdout || '').trim().split(/\s+/).pop() === 'True'
633
+ }
634
+
594
635
  /**
595
636
  * Minimise the eNSP window, handing the desktop back to the user.
637
+ *
638
+ * `ShowWindow(SW_MINIMIZE)` minimises without moving the activation: the window
639
+ * is iconic but still holds the foreground, and Windows then treats the first
640
+ * taskbar click as an activation correction, so the window appears not to come
641
+ * back. Restoring the previous foreground window after minimising keeps the
642
+ * taskbar behaviour standard.
643
+ *
596
644
  * @param {string} hwnd
645
+ * @param {{previousForeground?: string|null}} [options]
646
+ * @returns {{iconic: boolean, handedBack: boolean, foreground: string|null}}
597
647
  */
598
- export function minimiseEnsp(hwnd) {
599
- runPowerShell(
600
- `[EnspWin]::ShowWindow([IntPtr]::new([int64]${hwnd}), 6) | Out-Null; 'ok'`,
601
- { timeoutMs: 15000 },
648
+ export function minimiseEnsp(hwnd, options = {}) {
649
+ const previous = options.previousForeground ?? null
650
+ const r = runPowerShell(
651
+ `
652
+ $h = [IntPtr]::new([int64]${hwnd})
653
+ [EnspWin]::ShowWindow($h, 6) | Out-Null
654
+ Start-Sleep -Milliseconds 150
655
+ $fg = [EnspWin]::GetForegroundWindow()
656
+ $iconic = [EnspWin]::IsIconic($h)
657
+ $back = $false
658
+ if ($iconic -and $fg -eq $h) {
659
+ $prev = $null
660
+ ${previous ? `$prev = [IntPtr]::new([int64]${previous})` : ''}
661
+ if ($prev -ne $null -and $prev -ne [IntPtr]::Zero) {
662
+ $back = [EnspWin]::SetForegroundWindow($prev)
663
+ }
664
+ $fg = [EnspWin]::GetForegroundWindow()
665
+ if ($fg -eq $h) { $back = $false }
666
+ }
667
+ "ICONIC=$iconic FOREGROUND=" + ([int64]$fg).ToString() + " BACK=$back"
668
+ `,
669
+ { timeoutMs: 20000 },
602
670
  )
671
+ const text = r.stdout || ''
672
+ const iconic = /ICONIC=True/i.test(text)
673
+ const handedBack = /BACK=True/i.test(text)
674
+ const m = /FOREGROUND=(-?\d+)/.exec(text)
675
+ return { iconic, handedBack, foreground: m ? m[1] : null }
603
676
  }
604
677
 
605
678
  /**
@@ -820,6 +893,8 @@ export async function startAllDevices({
820
893
  // Doing it the other way round reports the window that was on top a moment ago
821
894
  // (usually the terminal that launched us) as if it were a permanent blocker.
822
895
  report({ phase: 'focus', hwnd })
896
+ // Remember who had the foreground so the minimised eNSP does not keep it.
897
+ const previousForeground = minimise ? foregroundWindow() : null
823
898
  let focused = focusEnsp(hwnd)
824
899
  if (focused) await new Promise((resolve) => setTimeout(resolve, 300))
825
900
 
@@ -867,8 +942,8 @@ export async function startAllDevices({
867
942
 
868
943
  // Hand the desktop back immediately; boot continues without the window.
869
944
  if (minimise) {
870
- minimiseEnsp(hwnd)
871
- report({ phase: 'minimised' })
945
+ const handback = minimiseEnsp(hwnd, { previousForeground })
946
+ report({ phase: 'minimised', ...handback })
872
947
  }
873
948
 
874
949
  report({ phase: 'wait', devices: targets.length })
@@ -909,7 +984,12 @@ export function stopAllDevices(hwnd, options = {}) {
909
984
  anchors = { ...DEFAULT_ANCHORS, method: 'default', detail: {} }
910
985
  }
911
986
  }
987
+ const previousForeground = foregroundWindow()
912
988
  const focused = focusEnsp(hwnd)
913
989
  const result = clickToolbar(hwnd, { button: 'stop', anchors })
914
- return { focused, anchors, ...result }
990
+ // The click needs eNSP in front, but leaving it there surprises the user who
991
+ // asked for a stop: hand the foreground back without touching the window
992
+ // state, so the topology stays visible while the devices power down.
993
+ const handback = restoreForeground(previousForeground)
994
+ return { focused, anchors, handback, ...result }
915
995
  }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Locate the eNSP client and VBoxManage.
3
+ *
4
+ * Both used to be hardcoded to `E:\`, which made every machine with a normal
5
+ * install fail at launch. The lookup order is:
6
+ *
7
+ * 1. an explicit override (CLI flag, plugin config, or function argument)
8
+ * 2. `DSH_ENSP_EXE` / `ENSP_EXE` / `DSH_VBOX`
9
+ * 3. the Windows install registry
10
+ * 4. the usual install roots
11
+ * 5. `where` on PATH (VBoxManage only)
12
+ *
13
+ * Every candidate that was tried is reported on failure, so a wrong override
14
+ * produces a message that says what was looked at instead of a bare ENOENT.
15
+ */
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+ import { spawnSync } from 'node:child_process'
19
+
20
+ /** Registry keys an installer may have written the install root to. */
21
+ const ENSP_REGISTRY = [
22
+ ['HKLM\\SOFTWARE\\Huawei\\eNSP', 'InstallPath'],
23
+ ['HKLM\\SOFTWARE\\WOW6432Node\\Huawei\\eNSP', 'InstallPath'],
24
+ ['HKLM\\SOFTWARE\\Huawei\\eNSP', 'InstallDir'],
25
+ ]
26
+
27
+ /** Install roots seen on real machines, most common first. */
28
+ const ENSP_ROOTS = [
29
+ 'C:\\Program Files\\Huawei\\eNSP',
30
+ 'C:\\Program Files (x86)\\Huawei\\eNSP',
31
+ 'D:\\Huawei\\eNSP',
32
+ 'E:\\Huawei\\eNSP',
33
+ ]
34
+
35
+ /** Where VirtualBox installs itself, most common first. */
36
+ const VBOX_PATHS = [
37
+ 'C:\\Program Files\\Oracle\\VirtualBox\\VBoxManage.exe',
38
+ 'C:\\Program Files (x86)\\Oracle\\VirtualBox\\VBoxManage.exe',
39
+ 'D:\\VirtualBox\\VBoxManage.exe',
40
+ 'E:\\VirtualBox\\VBoxManage.exe',
41
+ ]
42
+
43
+ const isWindows = process.platform === 'win32'
44
+
45
+ /** True when the path exists and is a file. Never throws. */
46
+ function isFile(candidate) {
47
+ if (!candidate) return false
48
+ try {
49
+ return fs.statSync(candidate).isFile()
50
+ } catch {
51
+ return false
52
+ }
53
+ }
54
+
55
+ /** Read one string value out of the registry, or null. */
56
+ function registryValue(key, name) {
57
+ if (!isWindows) return null
58
+ const r = spawnSync('reg', ['query', key, '/v', name], { encoding: 'utf8', windowsHide: true })
59
+ if (r.status !== 0 || !r.stdout) return null
60
+ const m = new RegExp(`${name}\\s+REG_\\w+\\s+(.+)`).exec(r.stdout)
61
+ return m ? m[1].trim() : null
62
+ }
63
+
64
+ /** Expand `%VAR%` and strip surrounding quotes from a registry value. */
65
+ function cleanRoot(value) {
66
+ if (!value) return null
67
+ const unquoted = value.trim().replace(/^"|"$/g, '')
68
+ const expanded = unexpanded(unquoted)
69
+ return expanded || null
70
+ }
71
+
72
+ /** Expand Windows environment variables without depending on the shell. */
73
+ function unexpanded(value) {
74
+ return value.replace(/%([^%]+)%/g, (whole, name) => process.env[name] ?? whole)
75
+ }
76
+
77
+ /** Every candidate eNSP client path, in lookup order, with its source. */
78
+ export function enspCandidates(override) {
79
+ const out = []
80
+ if (override) out.push({ path: override, source: 'override' })
81
+ for (const name of ['DSH_ENSP_EXE', 'ENSP_EXE']) {
82
+ const v = process.env[name]
83
+ if (v) out.push({ path: v, source: name })
84
+ }
85
+ for (const [key, name] of ENSP_REGISTRY) {
86
+ const root = cleanRoot(registryValue(key, name))
87
+ if (root) out.push({ path: path.join(root, 'eNSP_Client.exe'), source: `${key}\\${name}` })
88
+ }
89
+ for (const root of ENSP_ROOTS) out.push({ path: path.join(root, 'eNSP_Client.exe'), source: 'install root' })
90
+ return out
91
+ }
92
+
93
+ /** Every candidate VBoxManage path, in lookup order, with its source. */
94
+ export function vboxCandidates(override) {
95
+ const out = []
96
+ if (override) out.push({ path: override, source: 'override' })
97
+ if (process.env.DSH_VBOX) out.push({ path: process.env.DSH_VBOX, source: 'DSH_VBOX' })
98
+ if (isWindows) {
99
+ const r = spawnSync('where', ['VBoxManage.exe'], { encoding: 'utf8', windowsHide: true })
100
+ const first = (r.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0]
101
+ if (first) out.push({ path: first, source: 'PATH' })
102
+ }
103
+ for (const p of VBOX_PATHS) out.push({ path: p, source: 'install root' })
104
+ return out
105
+ }
106
+
107
+ /** First existing candidate, or null. */
108
+ function firstExisting(candidates) {
109
+ for (const c of candidates) if (isFile(c.path)) return c
110
+ return null
111
+ }
112
+
113
+ /**
114
+ * Resolve the eNSP client executable.
115
+ *
116
+ * @param {string} [override] - explicit path from a flag or plugin config
117
+ * @returns {{path: string, source: string, tried: string[]}}
118
+ * @throws when nothing exists
119
+ */
120
+ export function resolveEnspExe(override) {
121
+ // An explicit path is a statement of intent, not a hint. Falling back to some
122
+ // other installation would run a different eNSP than the one that was asked
123
+ // for, and the user would see a working launch on the machine they pointed at
124
+ // and a mystery on the machine they meant.
125
+ if (override && !isFile(override)) {
126
+ throw new Error(
127
+ `the eNSP client path given does not exist: ${override}\n`
128
+ + 'Check the path, or drop --exe to let the tool search for an installation.',
129
+ )
130
+ }
131
+ const candidates = enspCandidates(override)
132
+ const hit = firstExisting(candidates)
133
+ if (hit) return { path: hit.path, source: hit.source, tried: candidates.map((c) => c.path) }
134
+ const tried = candidates.map((c) => ` ${c.path} (${c.source})`).join('\n')
135
+ throw new Error(
136
+ `eNSP client not found. Looked at:\n${tried}\n\n`
137
+ + 'Pass --exe, set DSH_ENSP_EXE, or configure the plugin, with the full path to eNSP_Client.exe. '
138
+ + 'Installing eNSP to its default location also works.',
139
+ )
140
+ }
141
+
142
+ /**
143
+ * Resolve VBoxManage.
144
+ *
145
+ * @param {string} [override]
146
+ * @returns {{path: string, source: string, tried: string[]}}
147
+ * @throws when nothing exists
148
+ */
149
+ export function resolveVBoxManage(override) {
150
+ if (override && !isFile(override)) {
151
+ throw new Error(
152
+ `the VBoxManage path given does not exist: ${override}\n`
153
+ + 'Check the path, or drop --vbox to let the tool search for an installation.',
154
+ )
155
+ }
156
+ const candidates = vboxCandidates(override)
157
+ const hit = firstExisting(candidates)
158
+ if (hit) return { path: hit.path, source: hit.source, tried: candidates.map((c) => c.path) }
159
+ const tried = candidates.map((c) => ` ${c.path} (${c.source})`).join('\n')
160
+ throw new Error(
161
+ `VBoxManage.exe not found. Looked at:\n${tried}\n\n`
162
+ + 'Pass --vbox, set DSH_VBOX, or add the VirtualBox install directory to PATH.',
163
+ )
164
+ }
165
+
166
+ /**
167
+ * Resolve the eNSP install root, which holds `res/items.xml` and the plugins.
168
+ *
169
+ * Derived from the client path when it can be found, so a machine that launches
170
+ * successfully also has a correct catalogue path.
171
+ *
172
+ * @param {{exe?: string, root?: string}} [options]
173
+ * @returns {string|null}
174
+ */
175
+ export function resolveEnspRoot(options = {}) {
176
+ if (options.root && fs.existsSync(options.root)) return options.root
177
+ if (options.exe) return path.dirname(options.exe)
178
+ const hit = firstExisting(enspCandidates())
179
+ return hit ? path.dirname(hit.path) : null
180
+ }
181
+
182
+ /** Non-throwing probe used by `doctor`, which reports instead of raising. */
183
+ export function probePaths({ exe, vbox } = {}) {
184
+ const ensp = firstExisting(enspCandidates(exe))
185
+ const box = firstExisting(vboxCandidates(vbox))
186
+ return {
187
+ ensp: ensp ? { ok: true, path: ensp.path, source: ensp.source } : { ok: false, path: null },
188
+ vbox: box ? { ok: true, path: box.path, source: box.source } : { ok: false, path: null },
189
+ }
190
+ }
@@ -22,10 +22,37 @@
22
22
  * @module dsh-ensp/provision
23
23
  */
24
24
 
25
- import { openConsole, viewOf } from './console.mjs'
25
+ import { openConsole } from './console.mjs'
26
26
 
27
27
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
28
28
 
29
+ /**
30
+ * Errors that mean "the device is already in the state you asked for".
31
+ *
32
+ * Re-running the same configuration is the normal way to work: change one line,
33
+ * apply again, look at the effect. VRP answers the unchanged lines with an error,
34
+ * and aborting the batch there makes the tool unusable for that loop. These
35
+ * specific replies are recorded as no-ops instead. Everything else still stops
36
+ * the batch, because a genuine rejection means the rest of the script is
37
+ * probably landing on the wrong object.
38
+ */
39
+ const IDEMPOTENT_RE =
40
+ /already exists|The VLAN already exists|address already exists|The object already exists|has already been created|is already configured|The route already exists|The ACL rule already exists/i
41
+
42
+ /**
43
+ * Whether a VRP error means "this line is already in effect".
44
+ *
45
+ * Exported so the behaviour can be tested without a device: the distinction
46
+ * between "already there" and "rejected" is the whole point of a re-runnable
47
+ * apply, and it is easy to widen the pattern until real failures are swallowed.
48
+ *
49
+ * @param {string} message
50
+ * @returns {boolean}
51
+ */
52
+ export function isIdempotentError(message) {
53
+ return IDEMPOTENT_RE.test(String(message ?? ''))
54
+ }
55
+
29
56
  /**
30
57
  * Split a configuration script into executable lines.
31
58
  *
@@ -111,21 +138,21 @@ export async function pushConfig({
111
138
  // not. Because we already entered system view above, a leading `system-view`
112
139
  // would be rejected ("Unrecognized command") and abort the batch. Treat it as
113
140
  // idempotent: skip it when we are already in the right place.
114
- if (/^system-view$/i.test(command) && viewOf(session.prompt) === 'system') {
141
+ if (/^system-view$/i.test(command) && session.view === 'system') {
115
142
  applied += 1
116
143
  transcript.push({ line, command, ok: true, skipped: 'already in system view', output: '' })
117
144
  report?.({ phase: 'command', device, line, command, ok: true, skipped: true })
118
145
  continue
119
146
  }
120
147
  // Likewise `return` from system view is a no-op noise source.
121
- if (/^return$/i.test(command) && viewOf(session.prompt) === 'system') {
148
+ if (/^return$/i.test(command) && session.view === 'system') {
122
149
  applied += 1
123
150
  transcript.push({ line, command, ok: true, skipped: 'already at top of system view', output: '' })
124
151
  report?.({ phase: 'command', device, line, command, ok: true, skipped: true })
125
152
  continue
126
153
  }
127
154
  // Guard against view drift: a previous `quit` may have dropped us out.
128
- if (viewOf(session.prompt) === 'user') {
155
+ if (session.view === 'user') {
129
156
  const back = await session.ensureSystemView()
130
157
  if (!back.ok) {
131
158
  return {
@@ -159,6 +186,15 @@ export async function pushConfig({
159
186
  })
160
187
  report({ phase: 'command', device, line, command, ok: result.ok, view: result.view })
161
188
 
189
+ if (!result.ok && isIdempotentError(result.error)) {
190
+ // Already in the requested state. Count it so the totals stay meaningful,
191
+ // and keep the transcript honest about what the device actually said.
192
+ applied += 1
193
+ transcript[transcript.length - 1].skipped = 'already in the requested state'
194
+ report({ phase: 'command', device, line, command, ok: true, skipped: true, view: result.view })
195
+ continue
196
+ }
197
+
162
198
  if (!result.ok) {
163
199
  if (stopOnError) {
164
200
  return {
package/src/dsh/index.mjs CHANGED
@@ -104,6 +104,45 @@ function stripRequired(node) {
104
104
  /** Declare a closed output object. */
105
105
  const obj = (properties) => compileObject(properties)
106
106
 
107
+ /**
108
+ * Fill in required properties that a return branch omitted.
109
+ *
110
+ * A tool has several branches, and some of them legitimately have nothing to
111
+ * report: when eNSP is not running there is no pid, no hwnd and no anchor. The
112
+ * consumer still benefits from a stable shape, so the branch does not have to
113
+ * repeat empty fields and the schema does not have to relax.
114
+ *
115
+ * Missing required keys get the empty value for their type. Keys the branch did
116
+ * return are never touched, so a wrong value still reaches the validator.
117
+ */
118
+ /**
119
+ * Fill in the branches a tool did not return.
120
+ *
121
+ * Exported so the contract can be tested without calling a device: every tool
122
+ * declares its full output shape, and a run that legitimately has nothing to say
123
+ * about one field must still satisfy that shape.
124
+ */
125
+ export function conform(schema, value) {
126
+ if (!schema || schema.type !== 'object') return value
127
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value
128
+ const out = { ...value }
129
+ for (const key of schema.required ?? []) {
130
+ if (out[key] !== undefined) continue
131
+ out[key] = emptyFor(schema.properties?.[key])
132
+ }
133
+ return out
134
+ }
135
+
136
+ /** The empty value for a schema node's type. */
137
+ function emptyFor(node) {
138
+ const type = node?.type
139
+ if (type === 'number' || type === 'integer') return 0
140
+ if (type === 'boolean') return false
141
+ if (type === 'array') return []
142
+ if (type === 'object') return {}
143
+ return ''
144
+ }
145
+
107
146
  /** One plain text content block. */
108
147
  const text = (value) => [{ type: 'text', text: String(value) }]
109
148
 
@@ -121,7 +160,8 @@ function add(tools, definition) {
121
160
  return tools.register({
122
161
  ...rest,
123
162
  async execute(args) {
124
- return execute(args ?? {})
163
+ const value = await execute(args ?? {})
164
+ return conform(definition.output?.schema, value)
125
165
  },
126
166
  })
127
167
  }