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:
oliver 2026-10-10 16:34:07 +08:00
parent d4f2459eb2
commit 6c9f323368
18 changed files with 593 additions and 167 deletions

View file

@ -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)
}
}

View file

@ -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'),

View file

@ -174,7 +174,7 @@ const MEMORY_DEFAULTS: NetxopsSettings = {
imTargetId: '',
imTargets: '',
groupOpsInPreset: true,
groupOpsPublic: false,
groupOpsPublic: true,
groupTopologyInPreset: false,
groupTopologyPublic: false,
groupBizMonitorInPreset: false,

View file

@ -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)),

View file

@ -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

View 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
}