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

@ -4,7 +4,7 @@
# Host-plane Netx Ops bridge: settings + credentials → connection publish. # Host-plane Netx Ops bridge: settings + credentials → connection publish.
# Ops-scoped `netx__*` tools mount from the agent preset (`dsh-netxops/tools`). # Ops-scoped `netx__*` tools mount from the agent preset (`dsh-netxops/tools`).
# Capability groups: ops in preset; topology / public off. # Capability groups: ops in preset + public on; topology off.
# Rule: one group ↔ one skill (netx-ops / netx-topology). # Rule: one group ↔ one skill (netx-ops / netx-topology).
# #
# IM + cron are separate profile bundles (pnpm blockExoticSubdeps forbids # IM + cron are separate profile bundles (pnpm blockExoticSubdeps forbids
@ -34,7 +34,7 @@
imTargetId: '' imTargetId: ''
imTargets: '' imTargets: ''
groupOpsInPreset: true groupOpsInPreset: true
groupOpsPublic: false groupOpsPublic: true
groupTopologyInPreset: false groupTopologyInPreset: false
groupTopologyPublic: false groupTopologyPublic: false
groupBizMonitorInPreset: true groupBizMonitorInPreset: true

View file

@ -1,6 +1,6 @@
# Host-plane Netx Ops bridge: settings + credentials → connection publish. # Host-plane Netx Ops bridge: settings + credentials → connection publish.
# Ops-scoped `netx__*` tools mount from the agent preset (`dsh-netxops/tools`). # Ops-scoped `netx__*` tools mount from the agent preset (`dsh-netxops/tools`).
# Capability groups: ops in preset; topology / public off. # Capability groups: ops in preset + public on; topology off.
# Rule: one group ↔ one skill (netx-ops / netx-topology). # Rule: one group ↔ one skill (netx-ops / netx-topology).
# #
# IM + cron are separate profile bundles (pnpm blockExoticSubdeps forbids # IM + cron are separate profile bundles (pnpm blockExoticSubdeps forbids
@ -30,7 +30,7 @@
imTargetId: '' imTargetId: ''
imTargets: '' imTargets: ''
groupOpsInPreset: true groupOpsInPreset: true
groupOpsPublic: false groupOpsPublic: true
groupTopologyInPreset: false groupTopologyInPreset: false
groupTopologyPublic: false groupTopologyPublic: false
groupBizMonitorInPreset: true groupBizMonitorInPreset: true

View file

@ -67,7 +67,7 @@ One command, three **direct** profile bundles. Do **not** nest IM/cron under net
Token fallback: `scripts/set-netx-token.ps1` / `.sh`. Token fallback: `scripts/set-netx-token.ps1` / `.sh`.
Optional: enable **关键告警推送** so this DSH dials `ws(s)://<apiUrl>/v1/integrations/dsh-alarm/ws`. Choose **投递到 DSH 会话** and/or **投递到 WhatsApp / IM**. For IM, install `dsh-im-ops`, create a delivery target, then paste `imBotId` / `imTargetId`. The card header shows live WSS status. Optional: enable **关键告警推送** so this DSH dials `ws(s)://<apiUrl>/v1/integrations/dsh-alarm/ws`. Choose **投递到 DSH 会话** and/or **投递到 WhatsApp / IM**. For IM, install `dsh-im-ops`, create a delivery target, then paste `imBotId` / `imTargetId`. The card header shows live WSS status.
Optional: **导出全部会话** downloads `dsh-sessions-<host>-<utc>.zip` via `GET /api/netxops.sessions.export` (browser download; works for cloud Hosts). Optional: **导出全部会话** downloads `dsh-sessions-<host>-<utc>.zip` via `GET /api/netxops.sessions.export` (browser download; works for cloud Hosts).
Capability groups: leave default for **ops**, or enable **topology** / **对其他预设公开** (new sessions after save). Capability groups: **ops** defaults on (in Netx Ops + published to other presets). Enable **topology** if needed; uncheck **对其他预设公开** to keep tools Netx-Ops-only. Capability toggles auto-save (new sessions after restart if Host just healed a sparse Desktop override).
Optional: **知识库** — set package root (MANIFEST v1.0); default mounts `_skills/kb-*` into Netx Ops; optional public. See [KNOWLEDGE_BASE.md](KNOWLEDGE_BASE.md). Optional: **知识库** — set package root (MANIFEST v1.0); default mounts `_skills/kb-*` into Netx Ops; optional public. See [KNOWLEDGE_BASE.md](KNOWLEDGE_BASE.md).
2. Restart after install/upgrade so the bundle patch layers apply. Open Settings → **Agent presets** → **Netx Ops** should appear. 2. Restart after install/upgrade so the bundle patch layers apply. Open Settings → **Agent presets** → **Netx Ops** should appear.
The Netx Ops preset is **standard + netxops tools/persona** (shell, search, plan, todo, web, workflows, … plus `netx__*`). Re-activate (or restart the Host) so `$DSH_HOME/.agent-presets/netxops` is recomposed from the current Harness `standard` preset. The Netx Ops preset is **standard + netxops tools/persona** (shell, search, plan, todo, web, workflows, … plus `netx__*`). Re-activate (or restart the Host) so `$DSH_HOME/.agent-presets/netxops` is recomposed from the current Harness `standard` preset.

View file

@ -1,6 +1,6 @@
// src/netx/capability-groups.ts // src/netx/capability-groups.ts
var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ var DEFAULT_CAPABILITY_GROUPS = Object.freeze({
ops: Object.freeze({ inPreset: true, public: false }), ops: Object.freeze({ inPreset: true, public: true }),
topology: Object.freeze({ inPreset: false, public: false }), topology: Object.freeze({ inPreset: false, public: false }),
bizMonitor: Object.freeze({ inPreset: true, public: false }) bizMonitor: Object.freeze({ inPreset: true, public: false })
}); });

View file

@ -1,6 +1,6 @@
// src/netx/capability-groups.ts // src/netx/capability-groups.ts
var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ var DEFAULT_CAPABILITY_GROUPS = Object.freeze({
ops: Object.freeze({ inPreset: true, public: false }), ops: Object.freeze({ inPreset: true, public: true }),
topology: Object.freeze({ inPreset: false, public: false }), topology: Object.freeze({ inPreset: false, public: false }),
bizMonitor: Object.freeze({ inPreset: true, public: false }) bizMonitor: Object.freeze({ inPreset: true, public: false })
}); });

View file

@ -1,6 +1,6 @@
// src/netx/capability-groups.ts // src/netx/capability-groups.ts
var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ var DEFAULT_CAPABILITY_GROUPS = Object.freeze({
ops: Object.freeze({ inPreset: true, public: false }), ops: Object.freeze({ inPreset: true, public: true }),
topology: Object.freeze({ inPreset: false, public: false }), topology: Object.freeze({ inPreset: false, public: false }),
bizMonitor: Object.freeze({ inPreset: true, public: false }) bizMonitor: Object.freeze({ inPreset: true, public: false })
}); });

View file

@ -1,6 +1,6 @@
// src/netx/capability-groups.ts // src/netx/capability-groups.ts
var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ var DEFAULT_CAPABILITY_GROUPS = Object.freeze({
ops: Object.freeze({ inPreset: true, public: false }), ops: Object.freeze({ inPreset: true, public: true }),
topology: Object.freeze({ inPreset: false, public: false }), topology: Object.freeze({ inPreset: false, public: false }),
bizMonitor: Object.freeze({ inPreset: true, public: false }) bizMonitor: Object.freeze({ inPreset: true, public: false })
}); });

View file

@ -1,6 +1,6 @@
// src/netx/capability-groups.ts // src/netx/capability-groups.ts
var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ var DEFAULT_CAPABILITY_GROUPS = Object.freeze({
ops: Object.freeze({ inPreset: true, public: false }), ops: Object.freeze({ inPreset: true, public: true }),
topology: Object.freeze({ inPreset: false, public: false }), topology: Object.freeze({ inPreset: false, public: false }),
bizMonitor: Object.freeze({ inPreset: true, public: false }) bizMonitor: Object.freeze({ inPreset: true, public: false })
}); });

View file

@ -1,6 +1,6 @@
// src/netx/capability-groups.ts // src/netx/capability-groups.ts
var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ var DEFAULT_CAPABILITY_GROUPS = Object.freeze({
ops: Object.freeze({ inPreset: true, public: false }), ops: Object.freeze({ inPreset: true, public: true }),
topology: Object.freeze({ inPreset: false, public: false }), topology: Object.freeze({ inPreset: false, public: false }),
bizMonitor: Object.freeze({ inPreset: true, public: false }) bizMonitor: Object.freeze({ inPreset: true, public: false })
}); });

View file

@ -2006,7 +2006,7 @@ class NetxopsCardController {
textField("nmsProvider"), textField("nmsProvider"),
textField("kbRoot"), textField("kbRoot"),
booleanFieldPersistFalse("groupOpsInPreset"), booleanFieldPersistFalse("groupOpsInPreset"),
booleanField("groupOpsPublic"), booleanFieldPersistFalse("groupOpsPublic"),
booleanField("groupTopologyInPreset"), booleanField("groupTopologyInPreset"),
booleanField("groupTopologyPublic"), booleanField("groupTopologyPublic"),
booleanFieldPersistFalse("groupBizMonitorInPreset"), booleanFieldPersistFalse("groupBizMonitorInPreset"),
@ -2753,7 +2753,7 @@ var MEMORY_DEFAULTS = {
imTargetId: "", imTargetId: "",
imTargets: "", imTargets: "",
groupOpsInPreset: true, groupOpsInPreset: true,
groupOpsPublic: false, groupOpsPublic: true,
groupTopologyInPreset: false, groupTopologyInPreset: false,
groupTopologyPublic: false, groupTopologyPublic: false,
groupBizMonitorInPreset: false, groupBizMonitorInPreset: false,

View file

@ -9,63 +9,253 @@ import * as DshSettings from "@deepseek-ai/dsh-settings";
// src/agent-preset-install.ts // src/agent-preset-install.ts
import { import {
cpSync, cpSync,
existsSync, existsSync as existsSync2,
mkdirSync, mkdirSync,
readFileSync, readFileSync as readFileSync2,
rmSync, rmSync,
writeFileSync writeFileSync as writeFileSync2
} from "node:fs"; } from "node:fs";
import { createRequire as createRequire2 } from "node:module"; import { createRequire as createRequire2 } from "node:module";
import { homedir } from "node:os"; import { homedir as homedir2 } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join as join2 } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
var NETXOPS_PRESET_ID = "netxops";
// src/netx/heal-profile-entry.ts
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
var DESKTOP_PATCH_BEGIN = "# BEGIN dsh-netxops-preset (managed)"; var DESKTOP_PATCH_BEGIN = "# BEGIN dsh-netxops-preset (managed)";
var DESKTOP_PATCH_END = "# END dsh-netxops-preset (managed)"; var DESKTOP_PATCH_END = "# END dsh-netxops-preset (managed)";
var PATCH_PLUGIN_INDENT = " "; var NETXOPS_HOST_CONFIG_DEFAULTS = Object.freeze({
function packageRoot() { apiUrl: "http://127.0.0.1:8890",
return join(dirname(fileURLToPath(import.meta.url)), ".."); lang: "zh",
} thinkingLanguage: "auto",
replyLanguage: "follow-user",
nmsProvider: "zte-ume",
tokenCredentialRef: "NETX_API_TOKEN",
toolCallTimeoutMs: 120000,
installAgentPreset: true,
alarmPushEnabled: false,
alarmDeliverDsh: true,
alarmDeliverIm: false,
imBotId: "",
imTargetId: "",
imTargets: "",
groupOpsInPreset: true,
groupOpsPublic: true,
groupTopologyInPreset: false,
groupTopologyPublic: false,
groupBizMonitorInPreset: true,
groupBizMonitorPublic: false,
kbRoot: "",
groupKbInPreset: true,
groupKbPublic: false
});
function resolveDshHome() { function resolveDshHome() {
const fromEnv = process.env.DSH_HOME?.trim(); const fromEnv = process.env.DSH_HOME?.trim();
if (fromEnv !== undefined && fromEnv.length > 0) if (fromEnv !== undefined && fromEnv.length > 0)
return fromEnv; return fromEnv;
return join(homedir(), ".dsh"); return join(homedir(), ".dsh");
} }
function managedLineMask(lines) {
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) {
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) {
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;
}
function findHostEntry(lines, skip) {
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;
let name;
const config = {};
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) {
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) {
const config = { ...NETXOPS_HOST_CONFIG_DEFAULTS, ...entry.config };
const out = [
"- 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;
}
function healNetxopsProfileEntries(logger) {
let rewritten = 0;
for (const profileName of ["desktop", "web"]) {
const patchPath = join(resolveDshHome(), "profiles", profileName, "cordis.patch.yml");
if (!existsSync(patchPath))
continue;
const raw = readFileSync(patchPath, "utf8");
const nl = raw.includes(`\r
`) ? `\r
` : `
`;
const lines = raw.replace(/\r\n/g, `
`).split(`
`);
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;
}
// src/agent-preset-install.ts
var NETXOPS_PRESET_ID = "netxops";
var DESKTOP_PATCH_BEGIN2 = "# BEGIN dsh-netxops-preset (managed)";
var DESKTOP_PATCH_END2 = "# END dsh-netxops-preset (managed)";
var PATCH_PLUGIN_INDENT = " ";
function packageRoot() {
return join2(dirname(fileURLToPath(import.meta.url)), "..");
}
function resolveDshHome2() {
const fromEnv = process.env.DSH_HOME?.trim();
if (fromEnv !== undefined && fromEnv.length > 0)
return fromEnv;
return join2(homedir2(), ".dsh");
}
function electronResourcesRoots() { function electronResourcesRoots() {
const roots = []; const roots = [];
const rp = process.resourcesPath; const rp = process.resourcesPath;
if (typeof rp === "string" && rp.length > 0) if (typeof rp === "string" && rp.length > 0)
roots.push(rp); roots.push(rp);
roots.push(join(dirname(process.execPath), "resources")); roots.push(join2(dirname(process.execPath), "resources"));
return roots; return roots;
} }
function readableFile(path) { function readableFile(path) {
try { try {
readFileSync(path, { encoding: "utf8", flag: "r" }); readFileSync2(path, { encoding: "utf8", flag: "r" });
return true; return true;
} catch { } catch {
return false; return false;
} }
} }
function resolveDesktopStandardPatchPath() { function resolveDesktopStandardPatchPath() {
const dshHome = resolveDshHome(); const dshHome = resolveDshHome2();
const envOverride = process.env.NETXOPS_STANDARD_PATCH?.trim(); const envOverride = process.env.NETXOPS_STANDARD_PATCH?.trim();
const candidates = []; const candidates = [];
if (envOverride) if (envOverride)
candidates.push(envOverride); candidates.push(envOverride);
for (const resources of electronResourcesRoots()) { for (const resources of electronResourcesRoots()) {
candidates.push(join(resources, "app.asar", "dsh", "node_modules", "@deepseek-ai", "dsh-web-app", "presets", "standard.patch.yml"), join(resources, "app.asar.unpacked", "dsh", "node_modules", "@deepseek-ai", "dsh-web-app", "presets", "standard.patch.yml")); candidates.push(join2(resources, "app.asar", "dsh", "node_modules", "@deepseek-ai", "dsh-web-app", "presets", "standard.patch.yml"), join2(resources, "app.asar.unpacked", "dsh", "node_modules", "@deepseek-ai", "dsh-web-app", "presets", "standard.patch.yml"));
} }
const requireAnchors = [ const requireAnchors = [
join(dshHome, "profiles", "desktop", "package.json"), join2(dshHome, "profiles", "desktop", "package.json"),
join(dshHome, "profiles", "desktop", "node_modules", "dsh-netxops", "package.json"), join2(dshHome, "profiles", "desktop", "node_modules", "dsh-netxops", "package.json"),
join(packageRoot(), "package.json"), join2(packageRoot(), "package.json"),
join(dshHome, "profiles", "node_modules", "@deepseek-ai", "dsh-web-app", "package.json"), join2(dshHome, "profiles", "node_modules", "@deepseek-ai", "dsh-web-app", "package.json"),
join(dshHome, "profiles", "web", "package.json") join2(dshHome, "profiles", "web", "package.json")
]; ];
for (const anchor of requireAnchors) { for (const anchor of requireAnchors) {
if (!existsSync(anchor)) if (!existsSync2(anchor))
continue; continue;
try { try {
const req = createRequire2(anchor); const req = createRequire2(anchor);
@ -73,7 +263,7 @@ function resolveDesktopStandardPatchPath() {
candidates.push(req.resolve("@deepseek-ai/dsh-web-app/presets/standard.patch.yml")); candidates.push(req.resolve("@deepseek-ai/dsh-web-app/presets/standard.patch.yml"));
} catch {} } catch {}
const pkg = dirname(req.resolve("@deepseek-ai/dsh-web-app/package.json")); const pkg = dirname(req.resolve("@deepseek-ai/dsh-web-app/package.json"));
candidates.push(join(pkg, "presets", "standard.patch.yml")); candidates.push(join2(pkg, "presets", "standard.patch.yml"));
} catch {} } catch {}
} }
for (const path of candidates) { for (const path of candidates) {
@ -83,38 +273,38 @@ function resolveDesktopStandardPatchPath() {
return null; return null;
} }
function resolveStandardPresetDir() { function resolveStandardPresetDir() {
const dshHome = resolveDshHome(); const dshHome = resolveDshHome2();
const candidates = [ const candidates = [
join(dshHome, "profiles", "desktop", "node_modules", "@deepseek-ai", "dsh-agent-presets", "presets", "standard"), join2(dshHome, "profiles", "desktop", "node_modules", "@deepseek-ai", "dsh-agent-presets", "presets", "standard"),
join(dshHome, "profiles", "web", "node_modules", "@deepseek-ai", "dsh-agent-presets", "presets", "standard"), join2(dshHome, "profiles", "web", "node_modules", "@deepseek-ai", "dsh-agent-presets", "presets", "standard"),
join(dirname(fileURLToPath(import.meta.url)), "..", "..", "DeepSeekHarness", "packages", "preset", "agent-presets", "presets", "standard"), join2(dirname(fileURLToPath(import.meta.url)), "..", "..", "DeepSeekHarness", "packages", "preset", "agent-presets", "presets", "standard"),
join(dshHome, "profiles", "node_modules", "@deepseek-ai", "dsh-agent-presets", "presets", "standard") join2(dshHome, "profiles", "node_modules", "@deepseek-ai", "dsh-agent-presets", "presets", "standard")
]; ];
for (const dir of candidates) { for (const dir of candidates) {
if (existsSync(join(dir, "agent.cordis.yml"))) if (existsSync2(join2(dir, "agent.cordis.yml")))
return dir; return dir;
} }
for (const nm of [ for (const nm of [
join(dshHome, "profiles", "desktop", "node_modules"), join2(dshHome, "profiles", "desktop", "node_modules"),
join(dshHome, "profiles", "web", "node_modules"), join2(dshHome, "profiles", "web", "node_modules"),
join(dshHome, "profiles", "node_modules") join2(dshHome, "profiles", "node_modules")
]) { ]) {
const pkgJson = join(nm, "@deepseek-ai", "dsh-agent-presets", "package.json"); const pkgJson = join2(nm, "@deepseek-ai", "dsh-agent-presets", "package.json");
if (!existsSync(pkgJson)) if (!existsSync2(pkgJson))
continue; continue;
try { try {
const req = createRequire2(pkgJson); const req = createRequire2(pkgJson);
const root = dirname(req.resolve("@deepseek-ai/dsh-agent-presets/package.json")); const root = dirname(req.resolve("@deepseek-ai/dsh-agent-presets/package.json"));
const dir = join(root, "presets", "standard"); const dir = join2(root, "presets", "standard");
if (existsSync(join(dir, "agent.cordis.yml"))) if (existsSync2(join2(dir, "agent.cordis.yml")))
return dir; return dir;
} catch {} } catch {}
} }
try { try {
const req = createRequire2(import.meta.url); const req = createRequire2(import.meta.url);
const root = dirname(req.resolve("@deepseek-ai/dsh-agent-presets/package.json")); const root = dirname(req.resolve("@deepseek-ai/dsh-agent-presets/package.json"));
const dir = join(root, "presets", "standard"); const dir = join2(root, "presets", "standard");
if (existsSync(join(dir, "agent.cordis.yml"))) if (existsSync2(join2(dir, "agent.cordis.yml")))
return dir; return dir;
} catch {} } catch {}
return null; return null;
@ -123,31 +313,31 @@ function desktopRuntimeLikelyPresent() {
if (typeof process.versions.electron === "string") if (typeof process.versions.electron === "string")
return true; return true;
for (const resources of electronResourcesRoots()) { for (const resources of electronResourcesRoots()) {
if (existsSync(join(resources, "app.asar"))) if (existsSync2(join2(resources, "app.asar")))
return true; return true;
} }
const localAppData = process.env.LOCALAPPDATA?.trim(); const localAppData = process.env.LOCALAPPDATA?.trim();
if (localAppData) { if (localAppData) {
if (existsSync(join(localAppData, "Programs", "DeepSeek Harness", "resources", "app.asar"))) { if (existsSync2(join2(localAppData, "Programs", "DeepSeek Harness", "resources", "app.asar"))) {
return true; return true;
} }
} }
return false; return false;
} }
function hostResolveBases() { function hostResolveBases() {
const dshHome = resolveDshHome(); const dshHome = resolveDshHome2();
const bases = []; const bases = [];
for (const resources of electronResourcesRoots()) { for (const resources of electronResourcesRoots()) {
bases.push(join(resources, "app.asar", "dsh", "package.json")); bases.push(join2(resources, "app.asar", "dsh", "package.json"));
bases.push(join(resources, "app.asar", "dsh", "node_modules")); bases.push(join2(resources, "app.asar", "dsh", "node_modules"));
} }
const localAppData = process.env.LOCALAPPDATA?.trim(); const localAppData = process.env.LOCALAPPDATA?.trim();
if (localAppData) { if (localAppData) {
const asarDsh = join(localAppData, "Programs", "DeepSeek Harness", "resources", "app.asar", "dsh"); const asarDsh = join2(localAppData, "Programs", "DeepSeek Harness", "resources", "app.asar", "dsh");
bases.push(join(asarDsh, "package.json"), join(asarDsh, "node_modules")); bases.push(join2(asarDsh, "package.json"), join2(asarDsh, "node_modules"));
} }
bases.push(join(dshHome, "profiles", "desktop", "package.json"), join(dshHome, "profiles", "desktop", "node_modules"), join(dshHome, "profiles", "web", "package.json"), join(dshHome, "profiles", "web", "node_modules"), join(dshHome, "profiles", "node_modules"), join(packageRoot(), "package.json")); bases.push(join2(dshHome, "profiles", "desktop", "package.json"), join2(dshHome, "profiles", "desktop", "node_modules"), join2(dshHome, "profiles", "web", "package.json"), join2(dshHome, "profiles", "web", "node_modules"), join2(dshHome, "profiles", "node_modules"), join2(packageRoot(), "package.json"));
return [...new Set(bases.filter((path) => path.includes("app.asar") || existsSync(path)))]; return [...new Set(bases.filter((path) => path.includes("app.asar") || existsSync2(path)))];
} }
function packageInstalledAbove(name, bases) { function packageInstalledAbove(name, bases) {
if (name.startsWith("cordis:") || name.startsWith("node:")) if (name.startsWith("cordis:") || name.startsWith("node:"))
@ -156,16 +346,16 @@ function packageInstalledAbove(name, bases) {
for (const base of bases) { for (const base of bases) {
let dir = base.endsWith("package.json") ? dirname(base) : base; let dir = base.endsWith("package.json") ? dirname(base) : base;
for (;; ) { for (;; ) {
if (readableFile(join(dir, "node_modules", pkg, "package.json"))) if (readableFile(join2(dir, "node_modules", pkg, "package.json")))
return true; return true;
if (readableFile(join(dir, pkg, "package.json"))) if (readableFile(join2(dir, pkg, "package.json")))
return true; return true;
const parent = dirname(dir); const parent = dirname(dir);
if (parent === dir) if (parent === dir)
break; break;
dir = parent; dir = parent;
} }
const requireAnchor = base.endsWith("package.json") ? base : existsSync(join(base, "package.json")) ? join(base, "package.json") : null; const requireAnchor = base.endsWith("package.json") ? base : existsSync2(join2(base, "package.json")) ? join2(base, "package.json") : null;
if (requireAnchor !== null) { if (requireAnchor !== null) {
try { try {
createRequire2(requireAnchor).resolve(`${pkg}/package.json`); createRequire2(requireAnchor).resolve(`${pkg}/package.json`);
@ -339,7 +529,7 @@ function personaEntryFromMarkdown(md) {
`); `);
} }
function readPresetMeta(metaPath) { function readPresetMeta(metaPath) {
const metaText = readFileSync(metaPath, "utf8"); const metaText = readFileSync2(metaPath, "utf8");
return { return {
name: (metaText.match(/^name:\s*(.+)$/m) || [])[1]?.trim() || "Netx Ops", name: (metaText.match(/^name:\s*(.+)$/m) || [])[1]?.trim() || "Netx Ops",
description: (metaText.match(/^description:\s*(.+)$/m) || [])[1]?.trim() || "", description: (metaText.match(/^description:\s*(.+)$/m) || [])[1]?.trim() || "",
@ -347,13 +537,13 @@ function readPresetMeta(metaPath) {
}; };
} }
function desktopProfilePresent() { function desktopProfilePresent() {
return existsSync(join(resolveDshHome(), "profiles", "desktop")); return existsSync2(join2(resolveDshHome2(), "profiles", "desktop"));
} }
function composeNetxopsPresetArtifacts() { function composeNetxopsPresetArtifacts() {
const overlayRoot = join(packageRoot(), "presets", NETXOPS_PRESET_ID); const overlayRoot = join2(packageRoot(), "presets", NETXOPS_PRESET_ID);
const personaPath = join(overlayRoot, "PERSONA.md"); const personaPath = join2(overlayRoot, "PERSONA.md");
const metaPath = join(overlayRoot, "preset.yml"); const metaPath = join2(overlayRoot, "preset.yml");
if (!existsSync(personaPath) || !existsSync(metaPath)) { if (!existsSync2(personaPath) || !existsSync2(metaPath)) {
throw new Error(`bundled preset overlay missing under ${overlayRoot}`); throw new Error(`bundled preset overlay missing under ${overlayRoot}`);
} }
const meta = readPresetMeta(metaPath); const meta = readPresetMeta(metaPath);
@ -368,7 +558,7 @@ function composeNetxopsPresetArtifacts() {
standardSource = patchPath; standardSource = patchPath;
standardKind = "desktop-patch"; standardKind = "desktop-patch";
standardDir = null; standardDir = null;
agentCordis = pluginsBodyToAgentCordis(extractPluginsBodyFromStandardPatch(readFileSync(patchPath, "utf8"))); agentCordis = pluginsBodyToAgentCordis(extractPluginsBodyFromStandardPatch(readFileSync2(patchPath, "utf8")));
} else { } else {
const dir = resolveStandardPresetDir(); const dir = resolveStandardPresetDir();
if (dir === null) { if (dir === null) {
@ -377,9 +567,9 @@ function composeNetxopsPresetArtifacts() {
standardSource = dir; standardSource = dir;
standardKind = "directory"; standardKind = "directory";
standardDir = dir; standardDir = dir;
agentCordis = readFileSync(join(dir, "agent.cordis.yml"), "utf8"); agentCordis = readFileSync2(join2(dir, "agent.cordis.yml"), "utf8");
} }
agentCordis = replaceTopLevelEntry(agentCordis, "persona", personaEntryFromMarkdown(readFileSync(personaPath, "utf8"))); agentCordis = replaceTopLevelEntry(agentCordis, "persona", personaEntryFromMarkdown(readFileSync2(personaPath, "utf8")));
agentCordis = ensureNetxopsToolsRow(agentCordis); agentCordis = ensureNetxopsToolsRow(agentCordis);
agentCordis = alignWorkflowEngineForHost(agentCordis, bases); agentCordis = alignWorkflowEngineForHost(agentCordis, bases);
if (!agentCordis.endsWith(` if (!agentCordis.endsWith(`
@ -411,23 +601,23 @@ ${pluginsBody}
return { standardSource, standardKind, standardDir, ...meta, agentCordis, declarativePatch }; return { standardSource, standardKind, standardDir, ...meta, agentCordis, declarativePatch };
} }
function installDirectoryPreset(artifacts, logger) { function installDirectoryPreset(artifacts, logger) {
const overlayRoot = join(packageRoot(), "presets", NETXOPS_PRESET_ID); const overlayRoot = join2(packageRoot(), "presets", NETXOPS_PRESET_ID);
const destParent = join(resolveDshHome(), ".agent-presets"); const destParent = join2(resolveDshHome2(), ".agent-presets");
const dest = join(destParent, NETXOPS_PRESET_ID); const dest = join2(destParent, NETXOPS_PRESET_ID);
mkdirSync(destParent, { recursive: true }); mkdirSync(destParent, { recursive: true });
if (existsSync(dest)) if (existsSync2(dest))
rmSync(dest, { recursive: true, force: true }); rmSync(dest, { recursive: true, force: true });
mkdirSync(dest, { recursive: true }); mkdirSync(dest, { recursive: true });
if (artifacts.standardDir !== null && existsSync(artifacts.standardDir)) { if (artifacts.standardDir !== null && existsSync2(artifacts.standardDir)) {
cpSync(artifacts.standardDir, dest, { recursive: true }); cpSync(artifacts.standardDir, dest, { recursive: true });
} }
writeFileSync(join(dest, "preset.yml"), readFileSync(join(overlayRoot, "preset.yml"), "utf8")); writeFileSync2(join2(dest, "preset.yml"), readFileSync2(join2(overlayRoot, "preset.yml"), "utf8"));
writeFileSync(join(dest, "PERSONA.md"), readFileSync(join(overlayRoot, "PERSONA.md"), "utf8")); writeFileSync2(join2(dest, "PERSONA.md"), readFileSync2(join2(overlayRoot, "PERSONA.md"), "utf8"));
writeFileSync(join(dest, "agent.cordis.yml"), artifacts.agentCordis); writeFileSync2(join2(dest, "agent.cordis.yml"), artifacts.agentCordis);
const skillsSrc = join(overlayRoot, "skills"); const skillsSrc = join2(overlayRoot, "skills");
if (existsSync(skillsSrc)) if (existsSync2(skillsSrc))
cpSync(skillsSrc, join(dest, "skills"), { recursive: true }); cpSync(skillsSrc, join2(dest, "skills"), { recursive: true });
writeFileSync(join(dest, ".dsh-netxops-managed"), [ writeFileSync2(join2(dest, ".dsh-netxops-managed"), [
`composed-from: ${artifacts.standardKind}`, `composed-from: ${artifacts.standardKind}`,
`standard-source: ${artifacts.standardSource}`, `standard-source: ${artifacts.standardSource}`,
`at: ${new Date().toISOString()}`, `at: ${new Date().toISOString()}`,
@ -439,11 +629,11 @@ function installDirectoryPreset(artifacts, logger) {
function syncDeclarativeIntoProfilePatch(profileName, declarativePatch, logger) { function syncDeclarativeIntoProfilePatch(profileName, declarativePatch, logger) {
if (profileName !== "desktop" && profileName !== "web") if (profileName !== "desktop" && profileName !== "web")
return; return;
const profileDir = join(resolveDshHome(), "profiles", profileName); const profileDir = join2(resolveDshHome2(), "profiles", profileName);
const patchPath = join(profileDir, "cordis.patch.yml"); const patchPath = join2(profileDir, "cordis.patch.yml");
if (!existsSync(profileDir)) if (!existsSync2(profileDir))
return; return;
let patch = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : `[] let patch = existsSync2(patchPath) ? readFileSync2(patchPath, "utf8") : `[]
`; `;
patch = stripManagedPresetBlocks(patch); patch = stripManagedPresetBlocks(patch);
patch = patch.replace(/^- id: preset-netxops\n(?: .*\n)*/m, ""); patch = patch.replace(/^- id: preset-netxops\n(?: .*\n)*/m, "");
@ -453,25 +643,25 @@ function syncDeclarativeIntoProfilePatch(profileName, declarativePatch, logger)
`) && patch.length > 0) `) && patch.length > 0)
patch += ` patch += `
`; `;
const managed = `${DESKTOP_PATCH_BEGIN} const managed = `${DESKTOP_PATCH_BEGIN2}
${declarativePatch.trimEnd()} ${declarativePatch.trimEnd()}
${DESKTOP_PATCH_END} ${DESKTOP_PATCH_END2}
`; `;
writeFileSync(patchPath, `${patch}${managed}`, "utf8"); writeFileSync2(patchPath, `${patch}${managed}`, "utf8");
logger.info("netxops: declarative preset synced into profiles/%s/cordis.patch.yml (restart %s to load)", profileName, profileName === "desktop" ? "Desktop" : "dsh web"); logger.info("netxops: declarative preset synced into profiles/%s/cordis.patch.yml (restart %s to load)", profileName, profileName === "desktop" ? "Desktop" : "dsh web");
} }
function stripManagedPresetBlocks(patch) { function stripManagedPresetBlocks(patch) {
let out = patch; let out = patch;
for (;; ) { for (;; ) {
const start = out.indexOf(DESKTOP_PATCH_BEGIN); const start = out.indexOf(DESKTOP_PATCH_BEGIN2);
if (start < 0) if (start < 0)
break; break;
const end = out.indexOf(DESKTOP_PATCH_END, start); const end = out.indexOf(DESKTOP_PATCH_END2, start);
if (end < 0) { if (end < 0) {
out = out.slice(0, start); out = out.slice(0, start);
break; break;
} }
let cut = end + DESKTOP_PATCH_END.length; let cut = end + DESKTOP_PATCH_END2.length;
if (out[cut] === "\r") if (out[cut] === "\r")
cut += 1; cut += 1;
if (out[cut] === ` if (out[cut] === `
@ -495,7 +685,7 @@ function ensureAgentPresetInstalled(logger) {
logger.error("netxops: failed to install directory preset: %s", error); logger.error("netxops: failed to install directory preset: %s", error);
} }
try { try {
writeFileSync(join(packageRoot(), "presets", "netxops.preset.patch.yml"), artifacts.declarativePatch); writeFileSync2(join2(packageRoot(), "presets", "netxops.preset.patch.yml"), artifacts.declarativePatch);
} catch {} } catch {}
try { try {
for (const profile of ["desktop", "web"]) { for (const profile of ["desktop", "web"]) {
@ -504,6 +694,11 @@ function ensureAgentPresetInstalled(logger) {
} catch (error) { } catch (error) {
logger.error("netxops: failed to sync declarative preset into profile patch: %s", error); logger.error("netxops: failed to sync declarative preset into profile patch: %s", error);
} }
try {
healNetxopsProfileEntries(logger);
} catch (error) {
logger.error("netxops: failed to heal host profile entry: %s", error);
}
} }
// src/netx/alarm-push-status.ts // src/netx/alarm-push-status.ts
var STORE_KEY = Symbol.for("dsh-netxops.alarm-push-status"); var STORE_KEY = Symbol.for("dsh-netxops.alarm-push-status");
@ -899,16 +1094,16 @@ async function deliverAlarmToIm(ctx, payload, options) {
} }
// src/netx/alarm-session.ts // src/netx/alarm-session.ts
import { homedir as homedir2 } from "node:os"; import { homedir as homedir3 } from "node:os";
import { join as join2 } from "node:path"; import { join as join3 } from "node:path";
var PRESET_ID = "netxops"; var PRESET_ID = "netxops";
var PERMISSION_PRESET = "default"; var PERMISSION_PRESET = "default";
var sticky = null; var sticky = null;
var deliveryChain = Promise.resolve(); var deliveryChain = Promise.resolve();
function resolveWorkspacePath() { function resolveWorkspacePath() {
const fromEnv = process.env.DSH_HOME?.trim(); const fromEnv = process.env.DSH_HOME?.trim();
const home = fromEnv && fromEnv.length > 0 ? fromEnv : join2(homedir2(), ".dsh"); const home = fromEnv && fromEnv.length > 0 ? fromEnv : join3(homedir3(), ".dsh");
return join2(home, "workspaces", "netxops-alarms"); return join3(home, "workspaces", "netxops-alarms");
} }
async function disposeSticky(handle) { async function disposeSticky(handle) {
if (!handle) if (!handle)
@ -1066,7 +1261,7 @@ async function dispatchAlarmToSinks(ctx, payload, options, sinks = {}) {
// src/netx/capability-groups.ts // src/netx/capability-groups.ts
var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ var DEFAULT_CAPABILITY_GROUPS = Object.freeze({
ops: Object.freeze({ inPreset: true, public: false }), ops: Object.freeze({ inPreset: true, public: true }),
topology: Object.freeze({ inPreset: false, public: false }), topology: Object.freeze({ inPreset: false, public: false }),
bizMonitor: Object.freeze({ inPreset: true, public: false }) bizMonitor: Object.freeze({ inPreset: true, public: false })
}); });
@ -1149,7 +1344,18 @@ function capabilityGroupsFromSettings(fields) {
return { return {
ops: { ops: {
inPreset: opsInPreset, 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 !== undefined);
if (legacy.length === 0)
return true;
return legacy.some((v) => v === true);
})()
}, },
topology: { topology: {
inPreset: src.groupTopologyInPreset === true || src.groupTopologyLayoutInPreset === true, inPreset: src.groupTopologyInPreset === true || src.groupTopologyLayoutInPreset === true,
@ -1179,29 +1385,29 @@ function toolNamesForGroups(groupIds) {
} }
// src/netx/group-skills.ts // src/netx/group-skills.ts
import { existsSync as existsSync2 } from "node:fs"; import { existsSync as existsSync3 } from "node:fs";
import { readdir, readFile, stat } from "node:fs/promises"; import { readdir, readFile, stat } from "node:fs/promises";
import { dirname as dirname2, join as join3 } from "node:path"; import { dirname as dirname2, join as join4 } from "node:path";
import { fileURLToPath as fileURLToPath2 } from "node:url"; import { fileURLToPath as fileURLToPath2 } from "node:url";
function opsSkillsRoot() { function opsSkillsRoot() {
const envRoot = process.env.NETX_SKILLS_ROOT?.trim(); const envRoot = process.env.NETX_SKILLS_ROOT?.trim();
if (envRoot && existsSync2(envRoot)) if (envRoot && existsSync3(envRoot))
return envRoot; return envRoot;
const here = dirname2(fileURLToPath2(import.meta.url)); const here = dirname2(fileURLToPath2(import.meta.url));
const siblingCandidates = [ const siblingCandidates = [
join3(here, "..", "..", "..", "netx", "skills"), join4(here, "..", "..", "..", "netx", "skills"),
join3(here, "..", "..", "netx", "skills") join4(here, "..", "..", "netx", "skills")
]; ];
for (const candidate of siblingCandidates) { for (const candidate of siblingCandidates) {
if (existsSync2(candidate)) if (existsSync3(candidate))
return candidate; return candidate;
} }
const packaged = [ const packaged = [
join3(here, "..", "presets", "netxops", "skills"), join4(here, "..", "presets", "netxops", "skills"),
join3(here, "..", "..", "presets", "netxops", "skills") join4(here, "..", "..", "presets", "netxops", "skills")
]; ];
for (const candidate of packaged) { for (const candidate of packaged) {
if (existsSync2(candidate)) if (existsSync3(candidate))
return candidate; return candidate;
} }
return packaged[0]; return packaged[0];
@ -1252,7 +1458,7 @@ function stripQuotes(value) {
return value; return value;
} }
async function loadSkillBundle(dir) { async function loadSkillBundle(dir) {
const skillPath = join3(dir, "SKILL.md"); const skillPath = join4(dir, "SKILL.md");
let raw; let raw;
try { try {
raw = await readFile(skillPath, "utf8"); raw = await readFile(skillPath, "utf8");
@ -1275,7 +1481,7 @@ async function loadSkillBundle(dir) {
}; };
} }
async function loadGroupSkills(skillsRoot, groupId) { async function loadGroupSkills(skillsRoot, groupId) {
const groupDir = join3(skillsRoot, SKILL_DIR_BY_GROUP[groupId]); const groupDir = join4(skillsRoot, SKILL_DIR_BY_GROUP[groupId]);
let entries; let entries;
try { try {
entries = await readdir(groupDir); entries = await readdir(groupDir);
@ -1285,7 +1491,7 @@ async function loadGroupSkills(skillsRoot, groupId) {
const skills = []; const skills = [];
const skipped = []; const skipped = [];
for (const entry of entries) { for (const entry of entries) {
const full = join3(groupDir, entry); const full = join4(groupDir, entry);
let isDir = false; let isDir = false;
try { try {
isDir = (await stat(full)).isDirectory(); isDir = (await stat(full)).isDirectory();
@ -1294,7 +1500,7 @@ async function loadGroupSkills(skillsRoot, groupId) {
} }
if (!isDir) if (!isDir)
continue; continue;
const skillPath = join3(full, "SKILL.md"); const skillPath = join4(full, "SKILL.md");
let raw; let raw;
try { try {
raw = await readFile(skillPath, "utf8"); raw = await readFile(skillPath, "utf8");
@ -1343,7 +1549,7 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) {
log?.warn?.("%s: skip skill dir %s (%s)", providerLabel, miss.directory, miss.reason); log?.warn?.("%s: skip skill dir %s (%s)", providerLabel, miss.directory, miss.reason);
} }
if (skills.length === 0) { if (skills.length === 0) {
log?.warn?.("%s: no skills loaded for group=%s from %s", providerLabel, groupId, join3(root, SKILL_DIR_BY_GROUP[groupId])); log?.warn?.("%s: no skills loaded for group=%s from %s", providerLabel, groupId, join4(root, SKILL_DIR_BY_GROUP[groupId]));
} }
for (const skill of skills) { for (const skill of skills) {
disposers.push(skillsApi.register({ disposers.push(skillsApi.register({
@ -1364,8 +1570,8 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) {
} }
// src/netx/kb-local-path.ts // src/netx/kb-local-path.ts
import { existsSync as existsSync3, realpathSync, statSync } from "node:fs"; import { existsSync as existsSync4, realpathSync, statSync } from "node:fs";
import { join as join4, relative, resolve, sep } from "node:path"; import { join as join5, relative, resolve, sep } from "node:path";
var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([ var KB_LOCAL_WRITABLE_ROOTS = Object.freeze([
"memories", "memories",
"drafts", "drafts",
@ -1476,7 +1682,7 @@ function resolveExistingWritableFile(localRoot, pathArg) {
} }
const checked = resolveWritableLocalPath(localRoot, ...relative(localRoot, absolutePath).split(/[/\\]/).filter(Boolean)); const checked = resolveWritableLocalPath(localRoot, ...relative(localRoot, absolutePath).split(/[/\\]/).filter(Boolean));
try { try {
if (existsSync3(checked.absolutePath)) { if (existsSync4(checked.absolutePath)) {
const realFile = realpathSync(checked.absolutePath); const realFile = realpathSync(checked.absolutePath);
const realLocal = realpathSync(localRoot); const realLocal = realpathSync(localRoot);
const realRel = relative(realLocal, realFile); const realRel = relative(realLocal, realFile);
@ -1504,16 +1710,16 @@ function resolveExistingWritableFile(localRoot, pathArg) {
return checked; return checked;
} }
function memoryDir(bucket) { function memoryDir(bucket) {
return join4("memories", MEMORY_BUCKET_DIR[bucket]); return join5("memories", MEMORY_BUCKET_DIR[bucket]);
} }
function suggestionDir(kind) { function suggestionDir(kind) {
return join4("suggestions", kind); return join5("suggestions", kind);
} }
function draftDir(domain) { function draftDir(domain) {
const d = domain?.trim(); const d = domain?.trim();
if (!d) if (!d)
return "drafts"; return "drafts";
return join4("drafts", sanitizeSlug(d, "misc")); return join5("drafts", sanitizeSlug(d, "misc"));
} }
function refDir(area, device) { function refDir(area, device) {
if (!REF_AREAS.includes(area)) { if (!REF_AREAS.includes(area)) {
@ -1524,9 +1730,9 @@ function refDir(area, device) {
if (!host) { if (!host) {
throw new Error("refs/devices requires device (host_name)"); throw new Error("refs/devices requires device (host_name)");
} }
return join4("refs", "devices", host); return join5("refs", "devices", host);
} }
return join4("refs", area); return join5("refs", area);
} }
// src/netx/kb-context-skill.ts // src/netx/kb-context-skill.ts
@ -1619,8 +1825,8 @@ function registerKbContextSkill(ctx, snapshot) {
} }
// src/netx/kb-manifest.ts // src/netx/kb-manifest.ts
import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync2, statSync as statSync2 } from "node:fs"; import { existsSync as existsSync5, readdirSync, readFileSync as readFileSync3, statSync as statSync2 } from "node:fs";
import { basename, dirname as dirname3, join as join5, resolve as resolve2 } from "node:path"; import { basename, dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
var EMPTY_CONTENT = { var EMPTY_CONTENT = {
hasRegions: false, hasRegions: false,
hasTheory: false, hasTheory: false,
@ -1673,8 +1879,8 @@ function findManifest(kbRoot, maxDepth = 3) {
if (!rootStat.isDirectory()) { if (!rootStat.isDirectory()) {
return { paths: [], error: `kbRoot is not a directory: ${root}` }; return { paths: [], error: `kbRoot is not a directory: ${root}` };
} }
const direct = join5(root, "MANIFEST.json"); const direct = join6(root, "MANIFEST.json");
if (existsSync4(direct)) { if (existsSync5(direct)) {
try { try {
if (statSync2(direct).isFile()) if (statSync2(direct).isFile())
return { paths: [direct] }; return { paths: [direct] };
@ -1684,8 +1890,8 @@ function findManifest(kbRoot, maxDepth = 3) {
const walk = (dir, depth) => { const walk = (dir, depth) => {
if (depth > maxDepth) if (depth > maxDepth)
return; return;
const candidate = join5(dir, "MANIFEST.json"); const candidate = join6(dir, "MANIFEST.json");
if (existsSync4(candidate)) { if (existsSync5(candidate)) {
try { try {
if (statSync2(candidate).isFile()) if (statSync2(candidate).isFile())
found.push(candidate); found.push(candidate);
@ -1702,7 +1908,7 @@ function findManifest(kbRoot, maxDepth = 3) {
for (const name of entries) { for (const name of entries) {
if (name === "node_modules" || name === ".git") if (name === "node_modules" || name === ".git")
continue; continue;
const full = join5(dir, name); const full = join6(dir, name);
let st; let st;
try { try {
st = statSync2(full); st = statSync2(full);
@ -1722,7 +1928,7 @@ function findManifest(kbRoot, maxDepth = 3) {
for (const name of topEntries) { for (const name of topEntries) {
if (name === "node_modules" || name === ".git") if (name === "node_modules" || name === ".git")
continue; continue;
const full = join5(root, name); const full = join6(root, name);
let st; let st;
try { try {
st = statSync2(full); st = statSync2(full);
@ -1840,7 +2046,7 @@ function resolveKbRoot(kbRoot, maxDepth = 3) {
const manifestPath = located.paths[0]; const manifestPath = located.paths[0];
let raw; let raw;
try { try {
raw = readFileSync2(manifestPath, "utf8"); raw = readFileSync3(manifestPath, "utf8");
} catch (error) { } catch (error) {
return errorSnapshot(`cannot read MANIFEST: ${error instanceof Error ? error.message : String(error)}`); return errorSnapshot(`cannot read MANIFEST: ${error instanceof Error ? error.message : String(error)}`);
} }
@ -1866,15 +2072,15 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
// src/netx/kb-local-ops.ts // src/netx/kb-local-ops.ts
import { import {
existsSync as existsSync5, existsSync as existsSync6,
mkdirSync as mkdirSync2, mkdirSync as mkdirSync2,
readdirSync as readdirSync2, readdirSync as readdirSync2,
readFileSync as readFileSync3, readFileSync as readFileSync4,
rmSync as rmSync2, rmSync as rmSync2,
statSync as statSync3, statSync as statSync3,
writeFileSync as writeFileSync2 writeFileSync as writeFileSync3
} from "node:fs"; } from "node:fs";
import { dirname as dirname4, join as join6 } from "node:path"; import { dirname as dirname4, join as join7 } from "node:path";
function requireLocalRoot(snapshot) { function requireLocalRoot(snapshot) {
const root = resolveKbLocalRoot(snapshot); const root = resolveKbLocalRoot(snapshot);
if (!root) { if (!root) {
@ -1920,12 +2126,12 @@ function createMemory(snapshot, args) {
const localRoot = requireLocalRoot(snapshot); const localRoot = requireLocalRoot(snapshot);
const name = memoryFileName({ date: args.date, slug: args.slug }); const name = memoryFileName({ date: args.date, slug: args.slug });
const target = resolveWritableLocalPath(localRoot, memoryDir(args.bucket), name); const target = resolveWritableLocalPath(localRoot, memoryDir(args.bucket), name);
if (existsSync5(target.absolutePath) && !args.overwrite) { if (existsSync6(target.absolutePath) && !args.overwrite) {
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
} }
mkdirSync2(dirname4(target.absolutePath), { recursive: true }); mkdirSync2(dirname4(target.absolutePath), { recursive: true });
const existed = existsSync5(target.absolutePath); const existed = existsSync6(target.absolutePath);
writeFileSync2(target.absolutePath, args.body.endsWith(` writeFileSync3(target.absolutePath, args.body.endsWith(`
`) ? args.body : `${args.body} `) ? args.body : `${args.body}
`, "utf8"); `, "utf8");
return { return {
@ -1939,12 +2145,12 @@ function createDraft(snapshot, args) {
const localRoot = requireLocalRoot(snapshot); const localRoot = requireLocalRoot(snapshot);
const name = draftFileName({ date: args.date, slug: args.slug }); const name = draftFileName({ date: args.date, slug: args.slug });
const target = resolveWritableLocalPath(localRoot, draftDir(args.domain), name); const target = resolveWritableLocalPath(localRoot, draftDir(args.domain), name);
if (existsSync5(target.absolutePath) && !args.overwrite) { if (existsSync6(target.absolutePath) && !args.overwrite) {
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
} }
mkdirSync2(dirname4(target.absolutePath), { recursive: true }); mkdirSync2(dirname4(target.absolutePath), { recursive: true });
const existed = existsSync5(target.absolutePath); const existed = existsSync6(target.absolutePath);
writeFileSync2(target.absolutePath, ensureDraftBody(args.body), "utf8"); writeFileSync3(target.absolutePath, ensureDraftBody(args.body), "utf8");
return { return {
ok: true, ok: true,
action: existed ? "updated" : "created", action: existed ? "updated" : "created",
@ -1956,12 +2162,12 @@ function createSuggestion(snapshot, args) {
const localRoot = requireLocalRoot(snapshot); const localRoot = requireLocalRoot(snapshot);
const name = suggestionFileName({ date: args.date, slug: args.slug }); const name = suggestionFileName({ date: args.date, slug: args.slug });
const target = resolveWritableLocalPath(localRoot, suggestionDir(args.kind), name); const target = resolveWritableLocalPath(localRoot, suggestionDir(args.kind), name);
if (existsSync5(target.absolutePath) && !args.overwrite) { if (existsSync6(target.absolutePath) && !args.overwrite) {
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
} }
mkdirSync2(dirname4(target.absolutePath), { recursive: true }); mkdirSync2(dirname4(target.absolutePath), { recursive: true });
const existed = existsSync5(target.absolutePath); const existed = existsSync6(target.absolutePath);
writeFileSync2(target.absolutePath, args.body.endsWith(` writeFileSync3(target.absolutePath, args.body.endsWith(`
`) ? args.body : `${args.body} `) ? args.body : `${args.body}
`, "utf8"); `, "utf8");
return { return {
@ -1975,12 +2181,12 @@ function createRef(snapshot, args) {
const localRoot = requireLocalRoot(snapshot); const localRoot = requireLocalRoot(snapshot);
const name = refFileName(args.slug); const name = refFileName(args.slug);
const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name); const target = resolveWritableLocalPath(localRoot, refDir(args.area, args.device), name);
if (existsSync5(target.absolutePath) && !args.overwrite) { if (existsSync6(target.absolutePath) && !args.overwrite) {
throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`); throw new Error(`file already exists (pass overwrite=true to replace): ${target.relativePath}`);
} }
mkdirSync2(dirname4(target.absolutePath), { recursive: true }); mkdirSync2(dirname4(target.absolutePath), { recursive: true });
const existed = existsSync5(target.absolutePath); const existed = existsSync6(target.absolutePath);
writeFileSync2(target.absolutePath, args.body.endsWith(` writeFileSync3(target.absolutePath, args.body.endsWith(`
`) ? args.body : `${args.body} `) ? args.body : `${args.body}
`, "utf8"); `, "utf8");
return { return {
@ -1993,13 +2199,13 @@ function createRef(snapshot, args) {
function updateLocalFile(snapshot, args) { function updateLocalFile(snapshot, args) {
const localRoot = requireLocalRoot(snapshot); const localRoot = requireLocalRoot(snapshot);
const target = resolveExistingWritableFile(localRoot, args.path); const target = resolveExistingWritableFile(localRoot, args.path);
if (!existsSync5(target.absolutePath)) { if (!existsSync6(target.absolutePath)) {
throw new Error(`file not found: ${target.relativePath}`); throw new Error(`file not found: ${target.relativePath}`);
} }
const body = target.writableRoot === "drafts" ? ensureDraftBody(args.body) : args.body.endsWith(` const body = target.writableRoot === "drafts" ? ensureDraftBody(args.body) : args.body.endsWith(`
`) ? args.body : `${args.body} `) ? args.body : `${args.body}
`; `;
writeFileSync2(target.absolutePath, body, "utf8"); writeFileSync3(target.absolutePath, body, "utf8");
return { return {
ok: true, ok: true,
action: "updated", action: "updated",
@ -2010,7 +2216,7 @@ function updateLocalFile(snapshot, args) {
function deleteLocalFile(snapshot, args) { function deleteLocalFile(snapshot, args) {
const localRoot = requireLocalRoot(snapshot); const localRoot = requireLocalRoot(snapshot);
const target = resolveExistingWritableFile(localRoot, args.path); const target = resolveExistingWritableFile(localRoot, args.path);
if (!existsSync5(target.absolutePath)) { if (!existsSync6(target.absolutePath)) {
throw new Error(`file not found: ${target.relativePath}`); throw new Error(`file not found: ${target.relativePath}`);
} }
rmSync2(target.absolutePath, { force: false }); rmSync2(target.absolutePath, { force: false });
@ -2031,7 +2237,7 @@ function walkFiles(dir, baseLocal, out) {
for (const name of entries) { for (const name of entries) {
if (name === "." || name === "..") if (name === "." || name === "..")
continue; continue;
const full = join6(dir, name); const full = join7(dir, name);
let st; let st;
try { try {
st = statSync3(full); st = statSync3(full);
@ -2056,7 +2262,7 @@ function listLocalFiles(snapshot, args = {}) {
const roots = args.root && args.root !== "all" ? [args.root] : [...KB_LOCAL_WRITABLE_ROOTS]; const roots = args.root && args.root !== "all" ? [args.root] : [...KB_LOCAL_WRITABLE_ROOTS];
const entries = []; const entries = [];
for (const r of roots) { for (const r of roots) {
walkFiles(join6(localRoot, r), localRoot, entries); walkFiles(join7(localRoot, r), localRoot, entries);
} }
entries.sort((a, b) => b.mtimeMs - a.mtimeMs); entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
const limit = Math.min(Math.max(args.limit ?? 50, 1), 200); const limit = Math.min(Math.max(args.limit ?? 50, 1), 200);
@ -2304,19 +2510,19 @@ function registerKbLocalTools(ctx, snapshot = getKbContext()) {
// src/netx/kb-pack-skills.ts // src/netx/kb-pack-skills.ts
import { readdir as readdir2, stat as stat2 } from "node:fs/promises"; import { readdir as readdir2, stat as stat2 } from "node:fs/promises";
import { join as join7 } from "node:path"; import { join as join8 } from "node:path";
function kbPackSkillsEnabled(snapshot) { function kbPackSkillsEnabled(snapshot) {
return snapshot.status === "configured" && snapshot.content.hasSkills === true; return snapshot.status === "configured" && snapshot.content.hasSkills === true;
} }
function resolveKbSkillsRoot(snapshot) { function resolveKbSkillsRoot(snapshot) {
const rel = snapshot.paths.skills?.trim() || "_skills"; const rel = snapshot.paths.skills?.trim() || "_skills";
return join7(snapshot.realRoot, rel); return join8(snapshot.realRoot, rel);
} }
function resolveKbLocalSkillsRoot(snapshot) { function resolveKbLocalSkillsRoot(snapshot) {
const rel = snapshot.paths.localSkills?.trim(); const rel = snapshot.paths.localSkills?.trim();
if (!rel) if (!rel)
return null; return null;
return join7(snapshot.realRoot, rel); return join8(snapshot.realRoot, rel);
} }
async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) { async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options) {
let entries; let entries;
@ -2332,7 +2538,7 @@ async function registerSkillsUnderRoot(ctx, skillsApi, root, provider, options)
return []; return [];
const disposers = []; const disposers = [];
for (const entry of entries) { for (const entry of entries) {
const full = join7(root, entry); const full = join8(root, entry);
let isDir = false; let isDir = false;
try { try {
isDir = (await stat2(full)).isDirectory(); isDir = (await stat2(full)).isDirectory();
@ -2546,7 +2752,7 @@ function watchNetxConnection(listener) {
} }
// src/netx/session-export.ts // src/netx/session-export.ts
import { homedir as homedir3, hostname as osHostname } from "node:os"; import { homedir as homedir4, hostname as osHostname } from "node:os";
// node_modules/.pnpm/fflate@0.8.3/node_modules/fflate/esm/index.mjs // node_modules/.pnpm/fflate@0.8.3/node_modules/fflate/esm/index.mjs
import { createRequire as createRequire3 } from "module"; import { createRequire as createRequire3 } from "module";
@ -5788,7 +5994,7 @@ var Config = z.object({
imTargetId: vol(z.string().default("")), imTargetId: vol(z.string().default("")),
nmsProvider: vol(z.string().default("zte-ume")), nmsProvider: vol(z.string().default("zte-ume")),
groupOpsInPreset: vol(z.boolean().default(true)), groupOpsInPreset: vol(z.boolean().default(true)),
groupOpsPublic: vol(z.boolean().default(false)), groupOpsPublic: vol(z.boolean().default(true)),
groupTopologyInPreset: vol(z.boolean().default(false)), groupTopologyInPreset: vol(z.boolean().default(false)),
groupTopologyPublic: vol(z.boolean().default(false)), groupTopologyPublic: vol(z.boolean().default(false)),
groupBizMonitorInPreset: vol(z.boolean().default(true)), groupBizMonitorInPreset: vol(z.boolean().default(true)),

View file

@ -1,6 +1,6 @@
{ {
"name": "dsh-netxops", "name": "dsh-netxops",
"version": "0.1.50", "version": "0.1.51",
"description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills", "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View file

@ -27,6 +27,7 @@ import { homedir } from 'node:os'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis'
import { healNetxopsProfileEntries } from './netx/heal-profile-entry.ts'
export const NETXOPS_PRESET_ID = 'netxops' export const NETXOPS_PRESET_ID = 'netxops'
@ -610,4 +611,12 @@ export function ensureAgentPresetInstalled(logger: Context['logger']): void {
} catch (error) { } catch (error) {
logger.error('netxops: failed to sync declarative preset into profile patch: %s', 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('nmsProvider'),
textField('kbRoot'), textField('kbRoot'),
booleanFieldPersistFalse('groupOpsInPreset'), booleanFieldPersistFalse('groupOpsInPreset'),
booleanField('groupOpsPublic'), // Default is true — must persist false explicitly or "off" snaps back.
booleanFieldPersistFalse('groupOpsPublic'),
booleanField('groupTopologyInPreset'), booleanField('groupTopologyInPreset'),
booleanField('groupTopologyPublic'), booleanField('groupTopologyPublic'),
booleanFieldPersistFalse('groupBizMonitorInPreset'), booleanFieldPersistFalse('groupBizMonitorInPreset'),

View file

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

View file

@ -145,7 +145,7 @@ export interface Config {
nmsProvider: string nmsProvider: string
/** ops tools/skills in the Netx Ops preset (default on) — NMS + CLI + paths. */ /** ops tools/skills in the Netx Ops preset (default on) — NMS + CLI + paths. */
groupOpsInPreset: boolean groupOpsInPreset: boolean
/** Publish ops tools/skills to other presets (default off). */ /** Publish ops tools/skills to other presets (default on). */
groupOpsPublic: boolean groupOpsPublic: boolean
/** topology canvas / layout tools in the Netx Ops preset (default off). */ /** topology canvas / layout tools in the Netx Ops preset (default off). */
groupTopologyInPreset: boolean groupTopologyInPreset: boolean
@ -193,7 +193,7 @@ export const Config: z<Config> = z.object({
imTargetId: vol(z.string().default('')), imTargetId: vol(z.string().default('')),
nmsProvider: vol(z.string().default('zte-ume')), nmsProvider: vol(z.string().default('zte-ume')),
groupOpsInPreset: vol(z.boolean().default(true)), groupOpsInPreset: vol(z.boolean().default(true)),
groupOpsPublic: vol(z.boolean().default(false)), groupOpsPublic: vol(z.boolean().default(true)),
groupTopologyInPreset: vol(z.boolean().default(false)), groupTopologyInPreset: vol(z.boolean().default(false)),
groupTopologyPublic: vol(z.boolean().default(false)), groupTopologyPublic: vol(z.boolean().default(false)),
groupBizMonitorInPreset: vol(z.boolean().default(true)), 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. * Default: ops in Ops preset; topology / bizMonitor and all public off.
*/ */
export const DEFAULT_CAPABILITY_GROUPS: NetxCapabilityGroups = Object.freeze({ 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 }), topology: Object.freeze({ inPreset: false, public: false }),
bizMonitor: Object.freeze({ inPreset: true, public: false }), bizMonitor: Object.freeze({ inPreset: true, public: false }),
}) })
@ -151,10 +151,17 @@ export function capabilityGroupsFromSettings(
return { return {
ops: { ops: {
inPreset: opsInPreset, inPreset: opsInPreset,
public: src.groupOpsPublic === true public: (() => {
|| src.groupNmsPublic === true if (src.groupOpsPublic !== undefined) return src.groupOpsPublic !== false
|| src.groupCommonPublic === true const legacy = [
|| src.groupManagedNePublic === true, 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: { topology: {
inPreset: src.groupTopologyInPreset === true 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
}