mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-14 20:20:45 +08:00
336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
/**
|
|
* Ensure the Host bridge row `dsh-netxops` exists in desktop/web profile patches.
|
|
*
|
|
* Two independent install channels drift apart after dirty reinstalls:
|
|
* - Preset channel: `ensureAgentPresetInstalled` rewrites the managed
|
|
* `preset-netxops` block into `cordis.patch.yml` on every Host apply.
|
|
* - Host channel: normally comes from the package `cordis.bundle.patch.yml`
|
|
* insert, merged only while the bundle stays selected.
|
|
*
|
|
* When the bundle host insert is lost, Netx Ops preset + `netxops-tools` can
|
|
* still load, but `getNetxConnection()` stays undefined forever → no tools.
|
|
* Call this from Host apply **and** from the preset tools plugin (chicken-egg
|
|
* recovery when only the preset side is still alive).
|
|
*/
|
|
|
|
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
import { homedir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
|
|
const DESKTOP_PATCH_BEGIN = '# BEGIN dsh-netxops-preset (managed)'
|
|
const DESKTOP_PATCH_END = '# END dsh-netxops-preset (managed)'
|
|
|
|
const HOST_HEAL_ONCE = Symbol.for('dsh-netxops.host-bridge-heal-attempted')
|
|
|
|
/** Defaults merged into missing keys only — never overwrite user values. */
|
|
export const NETXOPS_HOST_CONFIG_DEFAULTS: Readonly<Record<string, string | boolean | number>> = Object.freeze({
|
|
toolsEnabled: true,
|
|
groupOpsPresets: '',
|
|
groupTopologyPresets: '',
|
|
groupBizMonitorPresets: '',
|
|
groupKbPresets: '',
|
|
apiUrl: 'http://127.0.0.1:8890',
|
|
lang: 'zh',
|
|
thinkingLanguage: 'auto',
|
|
replyLanguage: 'follow-user',
|
|
nmsProvider: 'zte-ume',
|
|
tokenCredentialRef: 'NETX_API_TOKEN',
|
|
toolCallTimeoutMs: 120_000,
|
|
installAgentPreset: true,
|
|
alarmPushEnabled: false,
|
|
alarmDeliverDsh: true,
|
|
alarmDeliverIm: false,
|
|
imBotId: '',
|
|
imTargetId: '',
|
|
imTargets: '',
|
|
groupOpsInPreset: true,
|
|
/** Default off: ops stays Netx-Ops-only unless the operator publishes. */
|
|
groupOpsPublic: false,
|
|
groupTopologyInPreset: false,
|
|
groupTopologyPublic: false,
|
|
groupBizMonitorInPreset: true,
|
|
groupBizMonitorPublic: false,
|
|
kbRoot: '',
|
|
groupKbInPreset: true,
|
|
groupKbPublic: false,
|
|
})
|
|
|
|
export interface HostBridgeHealResult {
|
|
rewritten: number
|
|
inserted: number
|
|
}
|
|
|
|
function resolveDshHome(): string {
|
|
const fromEnv = process.env.DSH_HOME?.trim()
|
|
if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv
|
|
return join(homedir(), '.dsh')
|
|
}
|
|
|
|
function managedLineMask(lines: string[]): boolean[] {
|
|
const skip = lines.map(() => false)
|
|
let inside = false
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
const line = lines[i]!
|
|
if (line.includes(DESKTOP_PATCH_BEGIN)) inside = true
|
|
if (inside) skip[i] = true
|
|
if (line.includes(DESKTOP_PATCH_END)) inside = false
|
|
}
|
|
return skip
|
|
}
|
|
|
|
function parseScalar(raw: string): string | boolean | number {
|
|
const text = raw.trim()
|
|
if (text === 'true') return true
|
|
if (text === 'false') return false
|
|
if (/^-?\d+$/.test(text)) return Number(text)
|
|
if (
|
|
(text.startsWith('"') && text.endsWith('"'))
|
|
|| (text.startsWith("'") && text.endsWith("'"))
|
|
) {
|
|
return text.slice(1, -1)
|
|
}
|
|
return text
|
|
}
|
|
|
|
function formatScalar(value: string | boolean | number): string {
|
|
if (typeof value === 'boolean' || typeof value === 'number') return String(value)
|
|
if (value === '') return "''"
|
|
if (/[:#\n\r]|^\s|\s$/.test(value) || value.includes("'")) {
|
|
return JSON.stringify(value)
|
|
}
|
|
return value
|
|
}
|
|
|
|
interface HostEntry {
|
|
start: number
|
|
end: number
|
|
disabled?: boolean
|
|
name?: string
|
|
config: Record<string, string | boolean | number>
|
|
}
|
|
|
|
function findHostEntry(lines: string[], skip: boolean[]): HostEntry | null {
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
if (skip[i]) continue
|
|
if (lines[i] !== '- id: netxops') continue
|
|
let end = i + 1
|
|
while (end < lines.length) {
|
|
const line = lines[end]!
|
|
if (skip[end]) break
|
|
if (/^- id:/.test(line)) break
|
|
end += 1
|
|
}
|
|
while (end > i + 1 && lines[end - 1]!.trim() === '') end -= 1
|
|
|
|
let disabled: boolean | undefined
|
|
let name: string | undefined
|
|
const config: Record<string, string | boolean | number> = {}
|
|
let inConfig = false
|
|
for (let j = i + 1; j < end; j += 1) {
|
|
const line = lines[j]!
|
|
const disabledMatch = /^ disabled:\s*(.+)\s*$/.exec(line)
|
|
if (disabledMatch) {
|
|
disabled = parseScalar(disabledMatch[1]!) === true
|
|
inConfig = false
|
|
continue
|
|
}
|
|
const nameMatch = /^ name:\s*(.+)\s*$/.exec(line)
|
|
if (nameMatch) {
|
|
name = String(parseScalar(nameMatch[1]!))
|
|
inConfig = false
|
|
continue
|
|
}
|
|
if (/^ config:\s*$/.test(line)) {
|
|
inConfig = true
|
|
continue
|
|
}
|
|
if (inConfig) {
|
|
const cfg = /^ ([A-Za-z0-9_]+):\s*(.*?)\s*$/.exec(line)
|
|
if (cfg) {
|
|
config[cfg[1]!] = parseScalar(cfg[2]!)
|
|
continue
|
|
}
|
|
if (/^ \S/.test(line)) inConfig = false
|
|
}
|
|
}
|
|
return { start: i, end, disabled, name, config }
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** True when any non-managed row already names the host package (insert or id form). */
|
|
function hostBridgeNamedElsewhere(lines: string[], skip: boolean[]): boolean {
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
if (skip[i]) continue
|
|
if (/^\s+name:\s*['"]?dsh-netxops['"]?\s*$/.test(lines[i]!)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function needsHeal(entry: HostEntry): boolean {
|
|
if (entry.name !== 'dsh-netxops') return true
|
|
for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) {
|
|
if (!Object.hasOwn(entry.config, key)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Fill missing default keys only — never overwrite existing user values,
|
|
* and keep unknown user keys for round-trip safety.
|
|
*/
|
|
export function mergeHostConfigForHeal(
|
|
existing: Readonly<Record<string, string | boolean | number>>,
|
|
): Record<string, string | boolean | number> {
|
|
const out: Record<string, string | boolean | number> = { ...existing }
|
|
for (const [key, value] of Object.entries(NETXOPS_HOST_CONFIG_DEFAULTS)) {
|
|
if (!Object.hasOwn(out, key)) out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
function renderHostEntry(entry: Pick<HostEntry, 'disabled' | 'name' | 'config'>): string[] {
|
|
const config = mergeHostConfigForHeal(entry.config)
|
|
const out: string[] = [
|
|
'- id: netxops',
|
|
' name: dsh-netxops',
|
|
]
|
|
if (entry.disabled === true) out.push(' disabled: true')
|
|
else out.push(' disabled: false')
|
|
out.push(' config:')
|
|
for (const key of Object.keys(NETXOPS_HOST_CONFIG_DEFAULTS)) {
|
|
out.push(` ${key}: ${formatScalar(config[key]!)}`)
|
|
}
|
|
for (const [key, value] of Object.entries(config)) {
|
|
if (Object.hasOwn(NETXOPS_HOST_CONFIG_DEFAULTS, key)) continue
|
|
out.push(` ${key}: ${formatScalar(value)}`)
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Write only when the on-disk contents still match `expectedRaw`.
|
|
* Aborts (returns false) if Plugin Manager / another heal raced us.
|
|
*/
|
|
export function writePatchIfUnchanged(
|
|
patchPath: string,
|
|
expectedRaw: string,
|
|
nextRaw: string,
|
|
): boolean {
|
|
if (nextRaw === expectedRaw) return false
|
|
const existed = existsSync(patchPath)
|
|
const mtimeMs = existed ? statSync(patchPath).mtimeMs : 0
|
|
const current = existed ? readFileSync(patchPath, 'utf8') : ''
|
|
if (current !== expectedRaw) return false
|
|
if (existed && statSync(patchPath).mtimeMs !== mtimeMs) return false
|
|
writeFileSync(patchPath, nextRaw, 'utf8')
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Insert or expand the Host `dsh-netxops` row in desktop/web `cordis.patch.yml`.
|
|
* @returns counts of profiles rewritten (sparse) vs freshly inserted (missing).
|
|
*/
|
|
export function ensureNetxopsHostInProfilePatches(logger: Context['logger']): HostBridgeHealResult {
|
|
let rewritten = 0
|
|
let inserted = 0
|
|
for (const profileName of ['desktop', 'web'] as const) {
|
|
const profileDir = join(resolveDshHome(), 'profiles', profileName)
|
|
const patchPath = join(profileDir, 'cordis.patch.yml')
|
|
if (!existsSync(profileDir)) continue
|
|
const raw = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : ''
|
|
const nl = raw.includes('\r\n') ? '\r\n' : '\n'
|
|
const lines = raw.length > 0 ? raw.replace(/\r\n/g, '\n').split('\n') : []
|
|
const skip = managedLineMask(lines)
|
|
const entry = findHostEntry(lines, skip)
|
|
|
|
let nextLines: string[] | null = null
|
|
let kind: 'insert' | 'rewrite' | null = null
|
|
|
|
if (entry === null) {
|
|
if (hostBridgeNamedElsewhere(lines, skip)) continue
|
|
const rendered = renderHostEntry({ disabled: false, config: {} })
|
|
const beginIdx = lines.findIndex((line) => line.includes(DESKTOP_PATCH_BEGIN))
|
|
if (beginIdx >= 0) {
|
|
nextLines = [...lines.slice(0, beginIdx), ...rendered, '', ...lines.slice(beginIdx)]
|
|
} else if (lines.length === 0 || (lines.length === 1 && lines[0]!.trim() === '')) {
|
|
nextLines = [...rendered, '']
|
|
} else {
|
|
const body = [...lines]
|
|
while (body.length > 0 && body[body.length - 1]!.trim() === '') body.pop()
|
|
nextLines = [...body, '', ...rendered, '']
|
|
}
|
|
kind = 'insert'
|
|
} else if (needsHeal(entry)) {
|
|
nextLines = [
|
|
...lines.slice(0, entry.start),
|
|
...renderHostEntry(entry),
|
|
...lines.slice(entry.end),
|
|
]
|
|
kind = 'rewrite'
|
|
}
|
|
|
|
if (nextLines === null || kind === null) continue
|
|
const normalized = nextLines.join(nl)
|
|
const withNl = normalized.endsWith(nl) ? normalized : `${normalized}${nl}`
|
|
if (!writePatchIfUnchanged(patchPath, raw, withNl)) {
|
|
if (withNl !== raw) {
|
|
logger.warn(
|
|
'netxops: skipped heal write for profiles/%s/cordis.patch.yml '
|
|
+ '(file changed concurrently — Plugin Manager/HMR race)',
|
|
profileName,
|
|
)
|
|
}
|
|
continue
|
|
}
|
|
if (kind === 'insert') {
|
|
inserted += 1
|
|
logger.error(
|
|
'netxops: host bridge dsh-netxops was MISSING from profiles/%s/cordis.patch.yml — '
|
|
+ 'wrote it back. Fully quit and restart %s once so the host publishes the connection '
|
|
+ '(preset-only installs leave netxops-tools waiting forever).',
|
|
profileName,
|
|
profileName === 'desktop' ? 'Desktop' : 'dsh web',
|
|
)
|
|
} else {
|
|
rewritten += 1
|
|
logger.info(
|
|
'netxops: healed sparse host entry in profiles/%s/cordis.patch.yml '
|
|
+ '(public toggles hot-reload via settings; restart %s only if tools still missing)',
|
|
profileName,
|
|
profileName === 'desktop' ? 'Desktop' : 'dsh web',
|
|
)
|
|
}
|
|
}
|
|
return { rewritten, inserted }
|
|
}
|
|
|
|
/** @deprecated Use {@link ensureNetxopsHostInProfilePatches}. */
|
|
export function healNetxopsProfileEntries(logger: Context['logger']): number {
|
|
const result = ensureNetxopsHostInProfilePatches(logger)
|
|
return result.rewritten + result.inserted
|
|
}
|
|
|
|
/**
|
|
* Preset-side chicken-egg recovery: when tools mount with no connection, try
|
|
* once per process to restore the missing Host bridge into the profile patch.
|
|
*/
|
|
export function tryRecoverMissingHostBridge(logger: Context['logger'], pluginName: string): void {
|
|
const root = globalThis as typeof globalThis & { [HOST_HEAL_ONCE]?: boolean }
|
|
if (root[HOST_HEAL_ONCE] === true) return
|
|
root[HOST_HEAL_ONCE] = true
|
|
try {
|
|
const result = ensureNetxopsHostInProfilePatches(logger)
|
|
if (result.inserted === 0 && result.rewritten === 0) {
|
|
logger.warn(
|
|
'%s: no connection yet — host settings bridge not publishing. '
|
|
+ 'Check that profile cordis.yml contains name: dsh-netxops (not only preset-netxops). '
|
|
+ 'Recovery: dsh plugin remove dsh-netxops && dsh plugin add …, then fully restart.',
|
|
pluginName,
|
|
)
|
|
}
|
|
} catch (error) {
|
|
logger.warn('%s: host-bridge recover failed: %s', pluginName, error)
|
|
}
|
|
}
|