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.
- package/README.en.md +328 -0
- package/README.md +6 -2
- package/docs/UI-ANCHORS.md +7 -1
- package/package.json +2 -1
- package/src/cli/ensp.mjs +7 -0
- package/src/core/acceptance.mjs +20 -2
- package/src/core/build.mjs +14 -3
- package/src/core/compat.mjs +236 -227
- package/src/core/console.mjs +34 -16
- package/src/core/doctor.mjs +17 -14
- package/src/core/index.mjs +9 -2
- package/src/core/launcher.mjs +89 -9
- package/src/core/paths.mjs +190 -0
- package/src/core/provision.mjs +40 -4
- package/src/dsh/index.mjs +41 -1
package/src/core/launcher.mjs
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/core/provision.mjs
CHANGED
|
@@ -22,10 +22,37 @@
|
|
|
22
22
|
* @module dsh-ensp/provision
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import { openConsole
|
|
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) &&
|
|
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) &&
|
|
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 (
|
|
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
|
-
|
|
163
|
+
const value = await execute(args ?? {})
|
|
164
|
+
return conform(definition.output?.schema, value)
|
|
125
165
|
},
|
|
126
166
|
})
|
|
127
167
|
}
|