mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-20 11:10:44 +08:00
Heal sparse Desktop overrides and default-publish ops tools (0.1.51).
Plugin Manager often leaves a bare netxops enable stub that wipes Config on DSH 0.2; expand missing keys on Host apply, default groupOpsPublic on, and persist false explicitly so other installs get tools without hand-editing patches. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d4f2459eb2
commit
6c9f323368
18 changed files with 593 additions and 167 deletions
|
|
@ -27,6 +27,7 @@ import { homedir } from 'node:os'
|
|||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { healNetxopsProfileEntries } from './netx/heal-profile-entry.ts'
|
||||
|
||||
export const NETXOPS_PRESET_ID = 'netxops'
|
||||
|
||||
|
|
@ -610,4 +611,12 @@ export function ensureAgentPresetInstalled(logger: Context['logger']): void {
|
|||
} catch (error) {
|
||||
logger.error('netxops: failed to sync declarative preset into profile patch: %s', error)
|
||||
}
|
||||
try {
|
||||
// Plugin Manager often leaves `- id: netxops` / `disabled: false` with no
|
||||
// config; DSH 0.2 replaces the whole Config → capability/public flags never
|
||||
// apply. Expand missing keys from package defaults on every Host apply.
|
||||
healNetxopsProfileEntries(logger)
|
||||
} catch (error) {
|
||||
logger.error('netxops: failed to heal host profile entry: %s', error)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,7 +188,8 @@ export class NetxopsCardController {
|
|||
textField('nmsProvider'),
|
||||
textField('kbRoot'),
|
||||
booleanFieldPersistFalse('groupOpsInPreset'),
|
||||
booleanField('groupOpsPublic'),
|
||||
// Default is true — must persist false explicitly or "off" snaps back.
|
||||
booleanFieldPersistFalse('groupOpsPublic'),
|
||||
booleanField('groupTopologyInPreset'),
|
||||
booleanField('groupTopologyPublic'),
|
||||
booleanFieldPersistFalse('groupBizMonitorInPreset'),
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ const MEMORY_DEFAULTS: NetxopsSettings = {
|
|||
imTargetId: '',
|
||||
imTargets: '',
|
||||
groupOpsInPreset: true,
|
||||
groupOpsPublic: false,
|
||||
groupOpsPublic: true,
|
||||
groupTopologyInPreset: false,
|
||||
groupTopologyPublic: false,
|
||||
groupBizMonitorInPreset: false,
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ export interface Config {
|
|||
nmsProvider: string
|
||||
/** ops tools/skills in the Netx Ops preset (default on) — NMS + CLI + paths. */
|
||||
groupOpsInPreset: boolean
|
||||
/** Publish ops tools/skills to other presets (default off). */
|
||||
/** Publish ops tools/skills to other presets (default on). */
|
||||
groupOpsPublic: boolean
|
||||
/** topology canvas / layout tools in the Netx Ops preset (default off). */
|
||||
groupTopologyInPreset: boolean
|
||||
|
|
@ -193,7 +193,7 @@ export const Config: z<Config> = z.object({
|
|||
imTargetId: vol(z.string().default('')),
|
||||
nmsProvider: vol(z.string().default('zte-ume')),
|
||||
groupOpsInPreset: vol(z.boolean().default(true)),
|
||||
groupOpsPublic: vol(z.boolean().default(false)),
|
||||
groupOpsPublic: vol(z.boolean().default(true)),
|
||||
groupTopologyInPreset: vol(z.boolean().default(false)),
|
||||
groupTopologyPublic: vol(z.boolean().default(false)),
|
||||
groupBizMonitorInPreset: vol(z.boolean().default(true)),
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export interface NetxCapabilityGroupSettingsFields {
|
|||
* Default: ops in Ops preset; topology / bizMonitor and all public off.
|
||||
*/
|
||||
export const DEFAULT_CAPABILITY_GROUPS: NetxCapabilityGroups = Object.freeze({
|
||||
ops: Object.freeze({ inPreset: true, public: false }),
|
||||
ops: Object.freeze({ inPreset: true, public: true }),
|
||||
topology: Object.freeze({ inPreset: false, public: false }),
|
||||
bizMonitor: Object.freeze({ inPreset: true, public: false }),
|
||||
})
|
||||
|
|
@ -151,10 +151,17 @@ export function capabilityGroupsFromSettings(
|
|||
return {
|
||||
ops: {
|
||||
inPreset: opsInPreset,
|
||||
public: src.groupOpsPublic === true
|
||||
|| src.groupNmsPublic === true
|
||||
|| src.groupCommonPublic === true
|
||||
|| src.groupManagedNePublic === true,
|
||||
public: (() => {
|
||||
if (src.groupOpsPublic !== undefined) return src.groupOpsPublic !== false
|
||||
const legacy = [
|
||||
src.groupNmsPublic,
|
||||
src.groupCommonPublic,
|
||||
src.groupManagedNePublic,
|
||||
].filter((v): v is boolean => v !== undefined)
|
||||
// No explicit flag → default on (other presets see ops after install).
|
||||
if (legacy.length === 0) return true
|
||||
return legacy.some((v) => v === true)
|
||||
})(),
|
||||
},
|
||||
topology: {
|
||||
inPreset: src.groupTopologyInPreset === true
|
||||
|
|
|
|||
203
src/netx/heal-profile-entry.ts
Normal file
203
src/netx/heal-profile-entry.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* Heal sparse Desktop/Web profile overrides for the host `netxops` row.
|
||||
*
|
||||
* Plugin Manager often writes only:
|
||||
* - id: netxops
|
||||
* disabled: false
|
||||
* On DSH 0.2 that override **replaces the whole Config**, so capability /
|
||||
* public flags never land unless we re-expand the entry. Call this on Host
|
||||
* apply so every install (not just hand-edited machines) gets a usable row.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, 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)'
|
||||
|
||||
/** Defaults merged into missing keys only — never overwrite user values. */
|
||||
export const NETXOPS_HOST_CONFIG_DEFAULTS: Readonly<Record<string, string | boolean | number>> = Object.freeze({
|
||||
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 on: other presets inherit ops tools/skills after install. */
|
||||
groupOpsPublic: true,
|
||||
groupTopologyInPreset: false,
|
||||
groupTopologyPublic: false,
|
||||
groupBizMonitorInPreset: true,
|
||||
groupBizMonitorPublic: false,
|
||||
kbRoot: '',
|
||||
groupKbInPreset: true,
|
||||
groupKbPublic: false,
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function renderHostEntry(entry: HostEntry): string[] {
|
||||
const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config }
|
||||
const out: string[] = [
|
||||
'- id: netxops',
|
||||
" name: dsh-netxops",
|
||||
]
|
||||
if (entry.disabled === true) out.push(' disabled: true')
|
||||
else if (entry.disabled === false) out.push(' disabled: false')
|
||||
out.push(' config:')
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
out.push(` ${key}: ${formatScalar(value)}`)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand sparse `netxops` host overrides in desktop/web profile patches.
|
||||
* @returns number of profiles rewritten.
|
||||
*/
|
||||
export function healNetxopsProfileEntries(logger: Context['logger']): number {
|
||||
let rewritten = 0
|
||||
for (const profileName of ['desktop', 'web'] as const) {
|
||||
const patchPath = join(resolveDshHome(), 'profiles', profileName, 'cordis.patch.yml')
|
||||
if (!existsSync(patchPath)) continue
|
||||
const raw = readFileSync(patchPath, 'utf8')
|
||||
const nl = raw.includes('\r\n') ? '\r\n' : '\n'
|
||||
const lines = raw.replace(/\r\n/g, '\n').split('\n')
|
||||
// Drop trailing empty split artifact for rewrite math; re-join with file nl.
|
||||
const skip = managedLineMask(lines)
|
||||
const entry = findHostEntry(lines, skip)
|
||||
if (entry === null) continue
|
||||
if (!needsHeal(entry)) continue
|
||||
const rendered = renderHostEntry(entry)
|
||||
const next = [
|
||||
...lines.slice(0, entry.start),
|
||||
...rendered,
|
||||
...lines.slice(entry.end),
|
||||
].join(nl)
|
||||
const normalized = next.endsWith(nl) ? next : `${next}${nl}`
|
||||
if (normalized === raw) continue
|
||||
writeFileSync(patchPath, normalized, 'utf8')
|
||||
rewritten += 1
|
||||
logger.info(
|
||||
'netxops: healed sparse host entry in profiles/%s/cordis.patch.yml (restart %s to load capability defaults)',
|
||||
profileName,
|
||||
profileName === 'desktop' ? 'Desktop' : 'dsh web',
|
||||
)
|
||||
}
|
||||
return rewritten
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue